diff --git a/cp.txt b/cp.txt
new file mode 100644
index 0000000..7ce6d50
--- /dev/null
+++ b/cp.txt
@@ -0,0 +1 @@
+C:\Users\hp\.m2\repository\org\openjfx\javafx-controls\17.0.11\javafx-controls-17.0.11.jar;C:\Users\hp\.m2\repository\org\openjfx\javafx-controls\17.0.11\javafx-controls-17.0.11-win.jar;C:\Users\hp\.m2\repository\org\openjfx\javafx-graphics\17.0.11\javafx-graphics-17.0.11.jar;C:\Users\hp\.m2\repository\org\openjfx\javafx-graphics\17.0.11\javafx-graphics-17.0.11-win.jar;C:\Users\hp\.m2\repository\org\openjfx\javafx-base\17.0.11\javafx-base-17.0.11.jar;C:\Users\hp\.m2\repository\org\openjfx\javafx-base\17.0.11\javafx-base-17.0.11-win.jar;C:\Users\hp\.m2\repository\org\openjfx\javafx-fxml\17.0.11\javafx-fxml-17.0.11.jar;C:\Users\hp\.m2\repository\org\openjfx\javafx-fxml\17.0.11\javafx-fxml-17.0.11-win.jar;C:\Users\hp\.m2\repository\org\xerial\sqlite-jdbc\3.45.1.0\sqlite-jdbc-3.45.1.0.jar;C:\Users\hp\.m2\repository\org\slf4j\slf4j-api\1.7.36\slf4j-api-1.7.36.jar;C:\Users\hp\.m2\repository\io\github\mkpaz\atlantafx-base\2.0.1\atlantafx-base-2.0.1.jar;C:\Users\hp\.m2\repository\org\kordamp\ikonli\ikonli-javafx\12.3.1\ikonli-javafx-12.3.1.jar;C:\Users\hp\.m2\repository\org\kordamp\ikonli\ikonli-core\12.3.1\ikonli-core-12.3.1.jar;C:\Users\hp\.m2\repository\org\kordamp\ikonli\ikonli-fontawesome5-pack\12.3.1\ikonli-fontawesome5-pack-12.3.1.jar
\ No newline at end of file
diff --git a/data/auction.db.sqlite b/data/auction.db.sqlite
index d81fc69..4b9f404 100644
Binary files a/data/auction.db.sqlite and b/data/auction.db.sqlite differ
diff --git a/data/auction.db.sqlite.bak.1779786816486 b/data/auction.db.sqlite.bak.1779786816486
new file mode 100644
index 0000000..9c6629a
Binary files /dev/null and b/data/auction.db.sqlite.bak.1779786816486 differ
diff --git a/data/auction.db.sqlite.bak.1779786866193 b/data/auction.db.sqlite.bak.1779786866193
new file mode 100644
index 0000000..9c6629a
Binary files /dev/null and b/data/auction.db.sqlite.bak.1779786866193 differ
diff --git a/data/auction.db.sqlite.bak.1779786916380 b/data/auction.db.sqlite.bak.1779786916380
new file mode 100644
index 0000000..9c6629a
Binary files /dev/null and b/data/auction.db.sqlite.bak.1779786916380 differ
diff --git a/data/images/07996bd1-e0d5-411f-8678-372a70fa92f4_1.jpg b/data/images/07996bd1-e0d5-411f-8678-372a70fa92f4_1.jpg
new file mode 100644
index 0000000..150cc9c
Binary files /dev/null and b/data/images/07996bd1-e0d5-411f-8678-372a70fa92f4_1.jpg differ
diff --git a/data/images/07996bd1-e0d5-411f-8678-372a70fa92f4_2.jpg b/data/images/07996bd1-e0d5-411f-8678-372a70fa92f4_2.jpg
new file mode 100644
index 0000000..50eea74
Binary files /dev/null and b/data/images/07996bd1-e0d5-411f-8678-372a70fa92f4_2.jpg differ
diff --git a/data/images/3685924a-6ae5-4416-9650-f540225f4179_1.jpg b/data/images/3685924a-6ae5-4416-9650-f540225f4179_1.jpg
new file mode 100644
index 0000000..91590eb
Binary files /dev/null and b/data/images/3685924a-6ae5-4416-9650-f540225f4179_1.jpg differ
diff --git a/data/images/3685924a-6ae5-4416-9650-f540225f4179_2.jpg b/data/images/3685924a-6ae5-4416-9650-f540225f4179_2.jpg
new file mode 100644
index 0000000..e1bfba5
Binary files /dev/null and b/data/images/3685924a-6ae5-4416-9650-f540225f4179_2.jpg differ
diff --git a/data/images/4420cdf9-9082-4aa5-b6db-f83d8fc87ea6_1.jpg b/data/images/4420cdf9-9082-4aa5-b6db-f83d8fc87ea6_1.jpg
new file mode 100644
index 0000000..91590eb
Binary files /dev/null and b/data/images/4420cdf9-9082-4aa5-b6db-f83d8fc87ea6_1.jpg differ
diff --git a/data/images/4420cdf9-9082-4aa5-b6db-f83d8fc87ea6_2.jpg b/data/images/4420cdf9-9082-4aa5-b6db-f83d8fc87ea6_2.jpg
new file mode 100644
index 0000000..e1bfba5
Binary files /dev/null and b/data/images/4420cdf9-9082-4aa5-b6db-f83d8fc87ea6_2.jpg differ
diff --git a/data/images/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_1.jpg b/data/images/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_1.jpg
new file mode 100644
index 0000000..11535ce
Binary files /dev/null and b/data/images/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_1.jpg differ
diff --git a/data/images/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_2.jpg b/data/images/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_2.jpg
new file mode 100644
index 0000000..f3776d9
Binary files /dev/null and b/data/images/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_2.jpg differ
diff --git a/data/images/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_3.jpg b/data/images/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_3.jpg
new file mode 100644
index 0000000..4d1d0f6
Binary files /dev/null and b/data/images/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_3.jpg differ
diff --git a/data/images/75e4cb9b-ca00-4c93-af0d-91bed8b76884_1.jpg b/data/images/75e4cb9b-ca00-4c93-af0d-91bed8b76884_1.jpg
new file mode 100644
index 0000000..f5a3a40
Binary files /dev/null and b/data/images/75e4cb9b-ca00-4c93-af0d-91bed8b76884_1.jpg differ
diff --git a/data/images/75e4cb9b-ca00-4c93-af0d-91bed8b76884_2.jpg b/data/images/75e4cb9b-ca00-4c93-af0d-91bed8b76884_2.jpg
new file mode 100644
index 0000000..22de80c
Binary files /dev/null and b/data/images/75e4cb9b-ca00-4c93-af0d-91bed8b76884_2.jpg differ
diff --git a/data/images/75e4cb9b-ca00-4c93-af0d-91bed8b76884_3.jpg b/data/images/75e4cb9b-ca00-4c93-af0d-91bed8b76884_3.jpg
new file mode 100644
index 0000000..f5a3a40
Binary files /dev/null and b/data/images/75e4cb9b-ca00-4c93-af0d-91bed8b76884_3.jpg differ
diff --git a/data/images/77519b9b-ac07-4b45-9cf9-c04090fbd1df_1.jpg b/data/images/77519b9b-ac07-4b45-9cf9-c04090fbd1df_1.jpg
new file mode 100644
index 0000000..5bfed20
Binary files /dev/null and b/data/images/77519b9b-ac07-4b45-9cf9-c04090fbd1df_1.jpg differ
diff --git a/data/images/77519b9b-ac07-4b45-9cf9-c04090fbd1df_2.jpg b/data/images/77519b9b-ac07-4b45-9cf9-c04090fbd1df_2.jpg
new file mode 100644
index 0000000..55ec582
Binary files /dev/null and b/data/images/77519b9b-ac07-4b45-9cf9-c04090fbd1df_2.jpg differ
diff --git a/data/images/77519b9b-ac07-4b45-9cf9-c04090fbd1df_3.jpg b/data/images/77519b9b-ac07-4b45-9cf9-c04090fbd1df_3.jpg
new file mode 100644
index 0000000..2226592
Binary files /dev/null and b/data/images/77519b9b-ac07-4b45-9cf9-c04090fbd1df_3.jpg differ
diff --git a/data/images/79acdbba-19fe-424c-acd7-f0f26f81e1d0_1.jpg b/data/images/79acdbba-19fe-424c-acd7-f0f26f81e1d0_1.jpg
new file mode 100644
index 0000000..91590eb
Binary files /dev/null and b/data/images/79acdbba-19fe-424c-acd7-f0f26f81e1d0_1.jpg differ
diff --git a/data/images/7d9d77dc-bea9-494c-ba8d-fda9c485e021_1.jpg b/data/images/7d9d77dc-bea9-494c-ba8d-fda9c485e021_1.jpg
new file mode 100644
index 0000000..a88c40b
Binary files /dev/null and b/data/images/7d9d77dc-bea9-494c-ba8d-fda9c485e021_1.jpg differ
diff --git a/data/images/7d9d77dc-bea9-494c-ba8d-fda9c485e021_2.jpg b/data/images/7d9d77dc-bea9-494c-ba8d-fda9c485e021_2.jpg
new file mode 100644
index 0000000..0ed9286
Binary files /dev/null and b/data/images/7d9d77dc-bea9-494c-ba8d-fda9c485e021_2.jpg differ
diff --git a/data/images/7d9d77dc-bea9-494c-ba8d-fda9c485e021_3.jpg b/data/images/7d9d77dc-bea9-494c-ba8d-fda9c485e021_3.jpg
new file mode 100644
index 0000000..a88c40b
Binary files /dev/null and b/data/images/7d9d77dc-bea9-494c-ba8d-fda9c485e021_3.jpg differ
diff --git a/data/images/7df0ea42-6c56-4cad-a029-05e09438b851_1.jpg b/data/images/7df0ea42-6c56-4cad-a029-05e09438b851_1.jpg
new file mode 100644
index 0000000..8be5db8
Binary files /dev/null and b/data/images/7df0ea42-6c56-4cad-a029-05e09438b851_1.jpg differ
diff --git a/data/images/7df0ea42-6c56-4cad-a029-05e09438b851_2.jpg b/data/images/7df0ea42-6c56-4cad-a029-05e09438b851_2.jpg
new file mode 100644
index 0000000..ea86d4d
Binary files /dev/null and b/data/images/7df0ea42-6c56-4cad-a029-05e09438b851_2.jpg differ
diff --git a/data/images/7df0ea42-6c56-4cad-a029-05e09438b851_3.jpg b/data/images/7df0ea42-6c56-4cad-a029-05e09438b851_3.jpg
new file mode 100644
index 0000000..fbecd05
Binary files /dev/null and b/data/images/7df0ea42-6c56-4cad-a029-05e09438b851_3.jpg differ
diff --git a/data/images/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_1.jpg b/data/images/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_1.jpg
new file mode 100644
index 0000000..05b4c36
Binary files /dev/null and b/data/images/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_1.jpg differ
diff --git a/data/images/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_2.jpg b/data/images/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_2.jpg
new file mode 100644
index 0000000..34cd794
Binary files /dev/null and b/data/images/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_2.jpg differ
diff --git a/data/images/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_3.jpg b/data/images/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_3.jpg
new file mode 100644
index 0000000..942026e
Binary files /dev/null and b/data/images/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_3.jpg differ
diff --git a/data/images/9f1fb1b2-2a1b-4d03-966b-8a1144f9060c_1.jpg b/data/images/9f1fb1b2-2a1b-4d03-966b-8a1144f9060c_1.jpg
new file mode 100644
index 0000000..91590eb
Binary files /dev/null and b/data/images/9f1fb1b2-2a1b-4d03-966b-8a1144f9060c_1.jpg differ
diff --git a/data/images/9f1fb1b2-2a1b-4d03-966b-8a1144f9060c_2.jpg b/data/images/9f1fb1b2-2a1b-4d03-966b-8a1144f9060c_2.jpg
new file mode 100644
index 0000000..e1bfba5
Binary files /dev/null and b/data/images/9f1fb1b2-2a1b-4d03-966b-8a1144f9060c_2.jpg differ
diff --git a/data/images/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_1.jpg b/data/images/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_1.jpg
new file mode 100644
index 0000000..91590eb
Binary files /dev/null and b/data/images/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_1.jpg differ
diff --git a/data/images/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_2.jpg b/data/images/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_2.jpg
new file mode 100644
index 0000000..e1bfba5
Binary files /dev/null and b/data/images/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_2.jpg differ
diff --git a/data/images/e8e5d89d-0828-4784-a3fb-9a8b843d6073_1.jpg b/data/images/e8e5d89d-0828-4784-a3fb-9a8b843d6073_1.jpg
new file mode 100644
index 0000000..712258b
Binary files /dev/null and b/data/images/e8e5d89d-0828-4784-a3fb-9a8b843d6073_1.jpg differ
diff --git a/data/images/e8e5d89d-0828-4784-a3fb-9a8b843d6073_2.jpg b/data/images/e8e5d89d-0828-4784-a3fb-9a8b843d6073_2.jpg
new file mode 100644
index 0000000..cf08848
Binary files /dev/null and b/data/images/e8e5d89d-0828-4784-a3fb-9a8b843d6073_2.jpg differ
diff --git a/data/images/e8e5d89d-0828-4784-a3fb-9a8b843d6073_3.jpg b/data/images/e8e5d89d-0828-4784-a3fb-9a8b843d6073_3.jpg
new file mode 100644
index 0000000..c81dbd0
Binary files /dev/null and b/data/images/e8e5d89d-0828-4784-a3fb-9a8b843d6073_3.jpg differ
diff --git a/data/images/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_1.jpg b/data/images/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_1.jpg
new file mode 100644
index 0000000..e3280ca
Binary files /dev/null and b/data/images/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_1.jpg differ
diff --git a/data/images/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_2.jpg b/data/images/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_2.jpg
new file mode 100644
index 0000000..50eea74
Binary files /dev/null and b/data/images/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_2.jpg differ
diff --git a/data/thumbs/07996bd1-e0d5-411f-8678-372a70fa92f4_1_thumb.jpg b/data/thumbs/07996bd1-e0d5-411f-8678-372a70fa92f4_1_thumb.jpg
new file mode 100644
index 0000000..150cc9c
Binary files /dev/null and b/data/thumbs/07996bd1-e0d5-411f-8678-372a70fa92f4_1_thumb.jpg differ
diff --git a/data/thumbs/3685924a-6ae5-4416-9650-f540225f4179_1_thumb.jpg b/data/thumbs/3685924a-6ae5-4416-9650-f540225f4179_1_thumb.jpg
new file mode 100644
index 0000000..91590eb
Binary files /dev/null and b/data/thumbs/3685924a-6ae5-4416-9650-f540225f4179_1_thumb.jpg differ
diff --git a/data/thumbs/4420cdf9-9082-4aa5-b6db-f83d8fc87ea6_1_thumb.jpg b/data/thumbs/4420cdf9-9082-4aa5-b6db-f83d8fc87ea6_1_thumb.jpg
new file mode 100644
index 0000000..91590eb
Binary files /dev/null and b/data/thumbs/4420cdf9-9082-4aa5-b6db-f83d8fc87ea6_1_thumb.jpg differ
diff --git a/data/thumbs/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_1_thumb.jpg b/data/thumbs/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_1_thumb.jpg
new file mode 100644
index 0000000..11535ce
Binary files /dev/null and b/data/thumbs/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_1_thumb.jpg differ
diff --git a/data/thumbs/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_2_thumb.jpg b/data/thumbs/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_2_thumb.jpg
new file mode 100644
index 0000000..f3776d9
Binary files /dev/null and b/data/thumbs/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_2_thumb.jpg differ
diff --git a/data/thumbs/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_3_thumb.jpg b/data/thumbs/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_3_thumb.jpg
new file mode 100644
index 0000000..4d1d0f6
Binary files /dev/null and b/data/thumbs/54f0aa0b-e5c9-40f6-bcfc-a787fdc04278_3_thumb.jpg differ
diff --git a/data/thumbs/75e4cb9b-ca00-4c93-af0d-91bed8b76884_1_thumb.jpg b/data/thumbs/75e4cb9b-ca00-4c93-af0d-91bed8b76884_1_thumb.jpg
new file mode 100644
index 0000000..f5a3a40
Binary files /dev/null and b/data/thumbs/75e4cb9b-ca00-4c93-af0d-91bed8b76884_1_thumb.jpg differ
diff --git a/data/thumbs/75e4cb9b-ca00-4c93-af0d-91bed8b76884_2_thumb.jpg b/data/thumbs/75e4cb9b-ca00-4c93-af0d-91bed8b76884_2_thumb.jpg
new file mode 100644
index 0000000..22de80c
Binary files /dev/null and b/data/thumbs/75e4cb9b-ca00-4c93-af0d-91bed8b76884_2_thumb.jpg differ
diff --git a/data/thumbs/75e4cb9b-ca00-4c93-af0d-91bed8b76884_3_thumb.jpg b/data/thumbs/75e4cb9b-ca00-4c93-af0d-91bed8b76884_3_thumb.jpg
new file mode 100644
index 0000000..f5a3a40
Binary files /dev/null and b/data/thumbs/75e4cb9b-ca00-4c93-af0d-91bed8b76884_3_thumb.jpg differ
diff --git a/data/thumbs/77519b9b-ac07-4b45-9cf9-c04090fbd1df_1_thumb.jpg b/data/thumbs/77519b9b-ac07-4b45-9cf9-c04090fbd1df_1_thumb.jpg
new file mode 100644
index 0000000..5bfed20
Binary files /dev/null and b/data/thumbs/77519b9b-ac07-4b45-9cf9-c04090fbd1df_1_thumb.jpg differ
diff --git a/data/thumbs/77519b9b-ac07-4b45-9cf9-c04090fbd1df_2_thumb.jpg b/data/thumbs/77519b9b-ac07-4b45-9cf9-c04090fbd1df_2_thumb.jpg
new file mode 100644
index 0000000..55ec582
Binary files /dev/null and b/data/thumbs/77519b9b-ac07-4b45-9cf9-c04090fbd1df_2_thumb.jpg differ
diff --git a/data/thumbs/77519b9b-ac07-4b45-9cf9-c04090fbd1df_3_thumb.jpg b/data/thumbs/77519b9b-ac07-4b45-9cf9-c04090fbd1df_3_thumb.jpg
new file mode 100644
index 0000000..2226592
Binary files /dev/null and b/data/thumbs/77519b9b-ac07-4b45-9cf9-c04090fbd1df_3_thumb.jpg differ
diff --git a/data/thumbs/79acdbba-19fe-424c-acd7-f0f26f81e1d0_1_thumb.jpg b/data/thumbs/79acdbba-19fe-424c-acd7-f0f26f81e1d0_1_thumb.jpg
new file mode 100644
index 0000000..91590eb
Binary files /dev/null and b/data/thumbs/79acdbba-19fe-424c-acd7-f0f26f81e1d0_1_thumb.jpg differ
diff --git a/data/thumbs/7d9d77dc-bea9-494c-ba8d-fda9c485e021_1_thumb.jpg b/data/thumbs/7d9d77dc-bea9-494c-ba8d-fda9c485e021_1_thumb.jpg
new file mode 100644
index 0000000..a88c40b
Binary files /dev/null and b/data/thumbs/7d9d77dc-bea9-494c-ba8d-fda9c485e021_1_thumb.jpg differ
diff --git a/data/thumbs/7d9d77dc-bea9-494c-ba8d-fda9c485e021_2_thumb.jpg b/data/thumbs/7d9d77dc-bea9-494c-ba8d-fda9c485e021_2_thumb.jpg
new file mode 100644
index 0000000..0ed9286
Binary files /dev/null and b/data/thumbs/7d9d77dc-bea9-494c-ba8d-fda9c485e021_2_thumb.jpg differ
diff --git a/data/thumbs/7d9d77dc-bea9-494c-ba8d-fda9c485e021_3_thumb.jpg b/data/thumbs/7d9d77dc-bea9-494c-ba8d-fda9c485e021_3_thumb.jpg
new file mode 100644
index 0000000..a88c40b
Binary files /dev/null and b/data/thumbs/7d9d77dc-bea9-494c-ba8d-fda9c485e021_3_thumb.jpg differ
diff --git a/data/thumbs/7df0ea42-6c56-4cad-a029-05e09438b851_1_thumb.jpg b/data/thumbs/7df0ea42-6c56-4cad-a029-05e09438b851_1_thumb.jpg
new file mode 100644
index 0000000..8be5db8
Binary files /dev/null and b/data/thumbs/7df0ea42-6c56-4cad-a029-05e09438b851_1_thumb.jpg differ
diff --git a/data/thumbs/7df0ea42-6c56-4cad-a029-05e09438b851_2_thumb.jpg b/data/thumbs/7df0ea42-6c56-4cad-a029-05e09438b851_2_thumb.jpg
new file mode 100644
index 0000000..ea86d4d
Binary files /dev/null and b/data/thumbs/7df0ea42-6c56-4cad-a029-05e09438b851_2_thumb.jpg differ
diff --git a/data/thumbs/7df0ea42-6c56-4cad-a029-05e09438b851_3_thumb.jpg b/data/thumbs/7df0ea42-6c56-4cad-a029-05e09438b851_3_thumb.jpg
new file mode 100644
index 0000000..fbecd05
Binary files /dev/null and b/data/thumbs/7df0ea42-6c56-4cad-a029-05e09438b851_3_thumb.jpg differ
diff --git a/data/thumbs/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_1_thumb.jpg b/data/thumbs/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_1_thumb.jpg
new file mode 100644
index 0000000..05b4c36
Binary files /dev/null and b/data/thumbs/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_1_thumb.jpg differ
diff --git a/data/thumbs/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_2_thumb.jpg b/data/thumbs/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_2_thumb.jpg
new file mode 100644
index 0000000..34cd794
Binary files /dev/null and b/data/thumbs/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_2_thumb.jpg differ
diff --git a/data/thumbs/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_3_thumb.jpg b/data/thumbs/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_3_thumb.jpg
new file mode 100644
index 0000000..942026e
Binary files /dev/null and b/data/thumbs/7feaf967-1d02-4dcf-8ebd-4ab6d16ffb98_3_thumb.jpg differ
diff --git a/data/thumbs/9f1fb1b2-2a1b-4d03-966b-8a1144f9060c_1_thumb.jpg b/data/thumbs/9f1fb1b2-2a1b-4d03-966b-8a1144f9060c_1_thumb.jpg
new file mode 100644
index 0000000..91590eb
Binary files /dev/null and b/data/thumbs/9f1fb1b2-2a1b-4d03-966b-8a1144f9060c_1_thumb.jpg differ
diff --git a/data/thumbs/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_1_thumb.jpg b/data/thumbs/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_1_thumb.jpg
new file mode 100644
index 0000000..91590eb
Binary files /dev/null and b/data/thumbs/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_1_thumb.jpg differ
diff --git a/data/thumbs/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_2_thumb.jpg b/data/thumbs/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_2_thumb.jpg
new file mode 100644
index 0000000..302dd88
Binary files /dev/null and b/data/thumbs/a724249b-3d8a-4b9d-a92b-2166e8f1be4a_2_thumb.jpg differ
diff --git a/data/thumbs/e8e5d89d-0828-4784-a3fb-9a8b843d6073_1_thumb.jpg b/data/thumbs/e8e5d89d-0828-4784-a3fb-9a8b843d6073_1_thumb.jpg
new file mode 100644
index 0000000..712258b
Binary files /dev/null and b/data/thumbs/e8e5d89d-0828-4784-a3fb-9a8b843d6073_1_thumb.jpg differ
diff --git a/data/thumbs/e8e5d89d-0828-4784-a3fb-9a8b843d6073_2_thumb.jpg b/data/thumbs/e8e5d89d-0828-4784-a3fb-9a8b843d6073_2_thumb.jpg
new file mode 100644
index 0000000..cf08848
Binary files /dev/null and b/data/thumbs/e8e5d89d-0828-4784-a3fb-9a8b843d6073_2_thumb.jpg differ
diff --git a/data/thumbs/e8e5d89d-0828-4784-a3fb-9a8b843d6073_3_thumb.jpg b/data/thumbs/e8e5d89d-0828-4784-a3fb-9a8b843d6073_3_thumb.jpg
new file mode 100644
index 0000000..c81dbd0
Binary files /dev/null and b/data/thumbs/e8e5d89d-0828-4784-a3fb-9a8b843d6073_3_thumb.jpg differ
diff --git a/data/thumbs/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_1_thumb.jpg b/data/thumbs/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_1_thumb.jpg
new file mode 100644
index 0000000..e3280ca
Binary files /dev/null and b/data/thumbs/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_1_thumb.jpg differ
diff --git a/data/thumbs/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_2_thumb.jpg b/data/thumbs/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_2_thumb.jpg
new file mode 100644
index 0000000..c5cf5d4
Binary files /dev/null and b/data/thumbs/ea66b308-5340-4bbb-a1e9-7cd491ed09ac_2_thumb.jpg differ
diff --git a/docs/M2_demo_plan.md b/docs/M2_demo_plan.md
new file mode 100644
index 0000000..f188696
--- /dev/null
+++ b/docs/M2_demo_plan.md
@@ -0,0 +1,42 @@
+# M2 Demo Plan — RTDAS
+
+Goal: record a 4–7 minute demo showing the stable gallery→detail→bid flow, connection-loss handling, and high-concurrency behavior.
+
+Segments:
+- Intro (20s): one-sentence problem statement and what's being shown.
+- Start server + client (30s): show terminal commands to start server and client. Use existing VS Code task `Run RTDAS Server` for server.
+- Gallery (60s): show category filter, sort, refresh, and open an auction detail (hero image).
+- Auction Detail (90s): show server-time countdown, bid-history refresh, place a bid, and show immediate UI updates.
+- Connection loss (45s): simulate network loss (stop server), show reconnect banner, then restart server and show recovery.
+- High-concurrency note (30s): mention stress test results and show test summary output (console). Optionally play a short snippet of test run output.
+- Wrap-up (20s): call-to-action, PR link, and where code lives.
+
+Prep checklist:
+- [ ] Ensure branch `task/member2` is pushed and tests pass locally.
+- [ ] Close other apps that may show notifications.
+- [ ] Set screen recording to 1920x1080, 30fps, 1280kbps audio.
+- [ ] Have terminal and app windows sized for readability.
+
+Recording commands (PowerShell) — start server:
+```powershell
+$root = 'd:\Real Time Distributed Auction System\Real-Time-Distributed-Auction-System'
+Set-Location $root
+mvn -DskipTests compile exec:java
+```
+
+Recording commands — run targeted tests (optional clip):
+```powershell
+Set-Location $root
+mvn -Dtest=com.auction.stress.ConcurrentBiddingHighStressTest test
+```
+
+How to simulate connection loss during demo:
+- In the server terminal, press Ctrl+C to stop the server; wait to show the reconnect banner on the client.
+- Restart server with the same command and show the client auto-reconnect.
+
+Deliverables I will produce if you want:
+- `docs/M2_demo_plan.md` (this file)
+- `scripts/record_demo.ps1` — helper to start server/test runs
+- Short script/narration lines for each segment (ready-to-read)
+
+Next step: tell me which deliverables you want me to create now (script, narration, or attempt to start server for a recording rehearsal).
diff --git a/exports/my_auctions_export.csv b/exports/my_auctions_export.csv
index 0a45b3e..bca4376 100644
--- a/exports/my_auctions_export.csv
+++ b/exports/my_auctions_export.csv
@@ -1 +1,10 @@
AuctionID,Title,Category,StartingPrice,FinalPrice,Winner,Status,StartTime,EndTime
+1,Vintage Camera,Electronics,100.0,105.0,bek1234,SOLD,2026-05-26T19:53:00Z,2026-05-26T19:58:00Z
+2,Smart Watch,Wearbles,1000.0,1000.0,,CANCELLED,2026-05-26T20:15:00Z,2026-05-26T20:20:00Z
+3,Handmade Oak Dining Table,Furniture,150.0,170.0,bek1234,SOLD,2026-05-27T15:02:00Z,2026-05-27T15:06:08.798246900Z
+4,Handcrafted Acoustic Guitar — Solid Spruce Top,Musical Instruments,150.0,160.0,bekam1234,SOLD,2026-05-27T15:42:00Z,2026-05-27T15:46:00Z
+5,Vintage Leica III,Electronics,500.0,640.0,bekam1234,SOLD,2026-05-27T19:41:00Z,2026-05-27T19:45:28.284658700Z
+6,Handmade Patchwork Quilt,Furniture,200.0,340.0,bek1234,SOLD,2026-05-27T21:11:00Z,2026-05-27T21:16:14.465744500Z
+7,Signed First Edition,Art,1200.0,1280.0,bekam1234,SOLD,2026-05-27T22:16:59.510155500Z,2026-05-27T22:19:59.510155500Z
+8,Antique Oak Side Table,Furniture,350.0,435.45,bek1234,SOLD,2026-05-28T00:31:00Z,2026-05-28T00:37:00Z
+9,Limited Print Poster,Art,8000.0,9000.0,bekam1234,SOLD,2026-05-28T00:34:07.295512800Z,2026-05-28T00:38:08.008908700Z
diff --git a/pom.xml b/pom.xml
index ef342bc..8269095 100644
--- a/pom.xml
+++ b/pom.xml
@@ -47,6 +47,13 @@
${junit.version}
test
+
+
+ org.openjfx
+ javafx-swing
+ ${javafx.version}
+ test
+
io.github.mkpaz
@@ -64,6 +71,12 @@
ikonli-fontawesome5-pack
12.3.1
+
+
+ io.github.nemanjastokuca
+ avif-imageio-native-reader
+ 0.1.0
+
diff --git a/sample_data.sql b/sample_data.sql
new file mode 100644
index 0000000..d3d9ded
--- /dev/null
+++ b/sample_data.sql
@@ -0,0 +1,32 @@
+BEGIN TRANSACTION;
+
+-- Users (simple hashes for local testing)
+INSERT OR IGNORE INTO users (username, password_hash, role, created_at) VALUES
+('seller2','5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8','USER','2026-05-01T00:00:00Z'),
+('seller3','5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8','USER','2026-05-01T00:00:00Z'),
+('seller4','5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8','USER','2026-05-01T00:00:00Z'),
+('seller5','5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8','USER','2026-05-01T00:00:00Z'),
+('bidder11','5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8','USER','2026-05-10T00:00:00Z'),
+('bidder12','5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8','USER','2026-05-10T00:00:00Z'),
+('bidder21','5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8','USER','2026-05-15T00:00:00Z'),
+('bidder31','5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8','USER','2026-05-20T00:00:00Z');
+
+-- Auction items with `category` using enum names (ELECTRONICS, FURNITURE, ART, OTHER)
+INSERT OR REPLACE INTO auction_items (id, title, description, category, starting_price_cents, current_bid_cents, highest_bidder_username, seller_username, start_time, end_time, cap_end_time, status, img1, img2, img3, relisted_from) VALUES
+(101, 'Vintage Leica III', 'Classic rangefinder, needs CLA', 'ELECTRONICS', 50000, 65000, 'bidder11', 'seller2', '2026-05-01T09:00:00Z', '2026-06-01T12:00:00Z', '2026-06-01T12:00:00Z', 'ACTIVE', NULL, NULL, NULL, NULL),
+(102, 'Handmade Patchwork Quilt', 'Queen size, hand-stitched', 'FURNITURE', 20000, 0, NULL, 'seller3', '2026-06-10T10:00:00Z', '2026-06-17T18:00:00Z', '2026-06-17T18:10:00Z', 'SCHEDULED', NULL, NULL, NULL, NULL),
+(103, 'Signed First Edition', 'Hardcover, excellent', 'ART', 120000, 150000, 'bidder21', 'seller4', '2026-05-20T08:00:00Z', '2026-05-30T20:00:00Z', '2026-05-30T20:00:00Z', 'ACTIVE', NULL, NULL, NULL, NULL),
+(104, 'Antique Oak Side Table', 'Restored, small blemish', 'FURNITURE', 30000, 45000, 'bidder31', 'seller5', '2026-04-01T08:00:00Z', '2026-04-07T20:00:00Z', '2026-04-07T20:00:00Z', 'SOLD', NULL, NULL, NULL, NULL),
+(105, 'Limited Print Poster', 'Framed, 24x36', 'ART', 8000, 9500, 'bidder31', 'seller2', '2026-05-25T12:00:00Z', '2026-06-05T22:00:00Z', '2026-06-05T22:05:00Z', 'ACTIVE', NULL, NULL, NULL, NULL),
+(106, 'Handcrafted Ceramic Vase', 'Artist-signed, small chip near base', 'OTHER', 6000, 7000, 'bidder12', 'seller3', '2026-06-01T09:00:00Z', '2026-06-08T17:00:00Z', '2026-06-08T17:00:00Z', 'SCHEDULED', NULL, NULL, NULL, NULL);
+
+-- Recent bids (explicit ids)
+INSERT OR REPLACE INTO bids (id, auction_item_id, bidder_username, amount_cents, timestamp) VALUES
+(1001, 101, 'bidder11', 65000, '2026-05-26T14:02:10Z'),
+(1002, 101, 'bidder12', 64000, '2026-05-26T13:55:05Z'),
+(1003, 103, 'bidder21', 150000, '2026-05-25T09:10:30Z'),
+(1004, 105, 'bidder31', 9500, '2026-05-26T10:22:00Z'),
+(1005, 105, 'bidder12', 9200, '2026-05-26T09:50:12Z'),
+(1006, 104, 'bidder31', 45000, '2026-04-07T20:00:00Z');
+
+COMMIT;
diff --git a/scripts/record_demo.ps1 b/scripts/record_demo.ps1
new file mode 100644
index 0000000..1b432f4
--- /dev/null
+++ b/scripts/record_demo.ps1
@@ -0,0 +1,20 @@
+#!/usr/bin/env pwsh
+# Helper script to start server and optionally run a stress test for recording.
+
+param(
+ [switch] $RunStressTest
+)
+
+$root = "d:\Real Time Distributed Auction System\Real-Time-Distributed-Auction-System"
+Set-Location $root
+
+Write-Host "Starting server (logs will stream)..."
+Start-Process -NoNewWindow -FilePath "mvn" -ArgumentList "-DskipTests","compile","exec:java" -WorkingDirectory $root
+
+if ($RunStressTest) {
+ Write-Host "Waiting 2s then running stress test (will print results)..."
+ Start-Sleep -Seconds 2
+ mvn -Dtest=com.auction.stress.ConcurrentBiddingHighStressTest test
+}
+
+Write-Host "Server started. Use your client to demo flows. Ctrl+C in this console will not stop the started process (find java process to stop)."
diff --git a/server_sources.txt b/server_sources.txt
new file mode 100644
index 0000000..290133d
--- /dev/null
+++ b/server_sources.txt
@@ -0,0 +1,38 @@
+src\main\java\com\auction\server\core\AuctionManager.java
+src\main\java\com\auction\server\core\ImageStore.java
+src\main\java\com\auction\server\core\LifecycleManager.java
+src\main\java\com\auction\server\core\LockManager.java
+src\main\java\com\auction\server\core\ServerBootstrap.java
+src\main\java\com\auction\server\core\ServerLauncher.java
+src\main\java\com\auction\server\core\SessionContext.java
+src\main\java\com\auction\server\core\TransactionManager.java
+src\main\java\com\auction\server\core\UdpBroadcaster.java
+src\main\java\com\auction\server\core\logging\AsyncLogger.java
+src\main\java\com\auction\server\core\logging\EventType.java
+src\main\java\com\auction\server\core\logging\LogCategory.java
+src\main\java\com\auction\server\core\logging\LogEntry.java
+src\main\java\com\auction\server\repository\AuctionRepository.java
+src\main\java\com\auction\server\repository\BidRepository.java
+src\main\java\com\auction\server\repository\DatabaseManager.java
+src\main\java\com\auction\server\repository\UserRepository.java
+src\main\java\com\auction\server\service\AuctionReaper.java
+src\main\java\com\auction\server\service\AuctionServiceImpl.java
+src\main\java\com\auction\server\util\AuditLogger.java
+src\main\java\com\auction\server\util\SecurityUtil.java
+src\main\java\com\auction\shared\Constants.java
+src\main\java\com\auction\shared\enums\AuctionStatus.java
+src\main\java\com\auction\shared\enums\Category.java
+src\main\java\com\auction\shared\exceptions\AuctionClosedException.java
+src\main\java\com\auction\shared\exceptions\AuctionException.java
+src\main\java\com\auction\shared\exceptions\DuplicateBidException.java
+src\main\java\com\auction\shared\exceptions\InsufficientBidException.java
+src\main\java\com\auction\shared\exceptions\RateLimitedException.java
+src\main\java\com\auction\shared\exceptions\SelfBidException.java
+src\main\java\com\auction\shared\exceptions\SnipeCapReachedException.java
+src\main\java\com\auction\shared\exceptions\StaleDataException.java
+src\main\java\com\auction\shared\exceptions\UnauthorizedException.java
+src\main\java\com\auction\shared\interfaces\IAuctionService.java
+src\main\java\com\auction\shared\models\Admin.java
+src\main\java\com\auction\shared\models\AuctionItem.java
+src\main\java\com\auction\shared\models\Bid.java
+src\main\java\com\auction\shared\models\User.java
diff --git a/sources.txt b/sources.txt
new file mode 100644
index 0000000..a8866ec
--- /dev/null
+++ b/sources.txt
@@ -0,0 +1,59 @@
+src\main\java\com\auction\ApplicationInfo.java
+src\main\java\com\auction\TestLoad.java
+src\main\java\com\auction\client\ClientApp.java
+src\main\java\com\auction\client\controllers\AdminPanelController.java
+src\main\java\com\auction\client\controllers\AuctionBidHistoryController.java
+src\main\java\com\auction\client\controllers\AuctionDetailController.java
+src\main\java\com\auction\client\controllers\ConnectController.java
+src\main\java\com\auction\client\controllers\GalleryController.java
+src\main\java\com\auction\client\controllers\LoginController.java
+src\main\java\com\auction\client\controllers\RegistrationController.java
+src\main\java\com\auction\client\controllers\UserDashboardController.java
+src\main\java\com\auction\client\core\ClientContext.java
+src\main\java\com\auction\client\core\ClientLauncher.java
+src\main\java\com\auction\client\network\RmiClientProvider.java
+src\main\java\com\auction\client\network\UdpDiscoveryClient.java
+src\main\java\com\auction\client\service\BidHistoryService.java
+src\main\java\com\auction\client\service\PollingService.java
+src\main\java\com\auction\client\service\ThumbnailExecutor.java
+src\main\java\com\auction\client\ui\ViewLoader.java
+src\main\java\com\auction\server\core\AuctionManager.java
+src\main\java\com\auction\server\core\ImageStore.java
+src\main\java\com\auction\server\core\LifecycleManager.java
+src\main\java\com\auction\server\core\LockManager.java
+src\main\java\com\auction\server\core\ServerBootstrap.java
+src\main\java\com\auction\server\core\ServerLauncher.java
+src\main\java\com\auction\server\core\SessionContext.java
+src\main\java\com\auction\server\core\TransactionManager.java
+src\main\java\com\auction\server\core\UdpBroadcaster.java
+src\main\java\com\auction\server\core\logging\AsyncLogger.java
+src\main\java\com\auction\server\core\logging\EventType.java
+src\main\java\com\auction\server\core\logging\LogCategory.java
+src\main\java\com\auction\server\core\logging\LogEntry.java
+src\main\java\com\auction\server\repository\AuctionRepository.java
+src\main\java\com\auction\server\repository\BidRepository.java
+src\main\java\com\auction\server\repository\DatabaseManager.java
+src\main\java\com\auction\server\repository\UserRepository.java
+src\main\java\com\auction\server\service\AuctionReaper.java
+src\main\java\com\auction\server\service\AuctionServiceImpl.java
+src\main\java\com\auction\server\util\AuditLogger.java
+src\main\java\com\auction\server\util\SecurityUtil.java
+src\main\java\com\auction\shared\Constants.java
+src\main\java\com\auction\shared\enums\AuctionStatus.java
+src\main\java\com\auction\shared\enums\Category.java
+src\main\java\com\auction\shared\exceptions\AuctionClosedException.java
+src\main\java\com\auction\shared\exceptions\AuctionException.java
+src\main\java\com\auction\shared\exceptions\DuplicateBidException.java
+src\main\java\com\auction\shared\exceptions\InsufficientBidException.java
+src\main\java\com\auction\shared\exceptions\RateLimitedException.java
+src\main\java\com\auction\shared\exceptions\SelfBidException.java
+src\main\java\com\auction\shared\exceptions\SnipeCapReachedException.java
+src\main\java\com\auction\shared\exceptions\StaleDataException.java
+src\main\java\com\auction\shared\exceptions\UnauthorizedException.java
+src\main\java\com\auction\shared\interfaces\IAuctionService.java
+src\main\java\com\auction\shared\models\Admin.java
+src\main\java\com\auction\shared\models\AuctionItem.java
+src\main\java\com\auction\shared\models\Bid.java
+src\main\java\com\auction\shared\models\User.java
+src\main\java\com\auction\tools\TestRegisterLogin.java
+src\main\java\com\auction\tools\UdpDiscoveryListener.java
diff --git a/src/main/java/com/auction/client/controllers/AuctionBidHistoryController.java b/src/main/java/com/auction/client/controllers/AuctionBidHistoryController.java
new file mode 100644
index 0000000..43f43f0
--- /dev/null
+++ b/src/main/java/com/auction/client/controllers/AuctionBidHistoryController.java
@@ -0,0 +1,110 @@
+package com.auction.client.controllers;
+
+import com.auction.client.core.ClientContext;
+import com.auction.client.service.BidHistoryService;
+import com.auction.shared.models.Bid;
+import javafx.application.Platform;
+import javafx.fxml.FXML;
+import javafx.scene.control.Alert;
+import javafx.scene.control.Button;
+import javafx.scene.control.Label;
+import javafx.scene.control.TableColumn;
+import javafx.scene.control.TableView;
+import javafx.scene.control.cell.PropertyValueFactory;
+
+public class AuctionBidHistoryController {
+
+ @FXML private Label titleLabel;
+ @FXML private Label subtitleLabel;
+ @FXML private Label auctionIdLabel;
+ @FXML private Button backButton;
+ @FXML private TableView bidHistoryTable;
+ @FXML private TableColumn timeColumn;
+ @FXML private TableColumn userColumn;
+ @FXML private TableColumn amountColumn;
+ @FXML private Label statusLabel;
+
+ @FXML
+ public void initialize() {
+ if (timeColumn != null) {
+ timeColumn.setCellValueFactory(new PropertyValueFactory<>("timestampFormatted"));
+ }
+ if (userColumn != null) {
+ userColumn.setCellValueFactory(new PropertyValueFactory<>("bidderUsername"));
+ }
+ if (amountColumn != null) {
+ amountColumn.setCellValueFactory(new PropertyValueFactory<>("amountCents"));
+ amountColumn.setCellFactory(col -> new javafx.scene.control.TableCell() {
+ @Override
+ protected void updateItem(Long value, boolean empty) {
+ super.updateItem(value, empty);
+ if (empty || value == null) {
+ setText(null);
+ } else {
+ setText(com.auction.shared.Constants.formatCents(value));
+ }
+ }
+ });
+ }
+
+ int auctionId = ClientContext.getInstance().getCurrentAuctionId();
+ if (auctionId >= 0) {
+ if (auctionIdLabel != null) {
+ auctionIdLabel.setText("Auction #" + auctionId);
+ }
+ loadHistory(auctionId);
+ } else {
+ if (statusLabel != null) statusLabel.setText("No auction selected.");
+ }
+ }
+
+ private void loadHistory(int auctionId) {
+ if (bidHistoryTable != null) {
+ bidHistoryTable.getItems().clear();
+ }
+ Platform.runLater(() -> {
+ if (statusLabel != null) statusLabel.setText("Loading bid history...");
+ });
+
+ BidHistoryService.loadBidHistoryAsync(auctionId).whenComplete((list, throwable) -> {
+ if (throwable != null) {
+ Platform.runLater(() -> {
+ if (statusLabel != null) statusLabel.setText("Failed to load bid history.");
+ Alert alert = new Alert(Alert.AlertType.ERROR);
+ alert.setTitle("Bid History Error");
+ alert.setHeaderText("Could not load bid history");
+ alert.setContentText(throwable.getCause() == null ? throwable.getMessage() : throwable.getCause().getMessage());
+ alert.showAndWait();
+ });
+ return;
+ }
+
+ Platform.runLater(() -> {
+ if (bidHistoryTable != null) {
+ bidHistoryTable.getItems().setAll(list == null ? java.util.List.of() : list);
+ }
+ if (statusLabel != null) {
+ int size = list == null ? 0 : list.size();
+ statusLabel.setText(size + (size == 1 ? " bid loaded" : " bids loaded"));
+ }
+ });
+ });
+ }
+
+ @FXML
+ private void handleRefreshHistory() {
+ int auctionId = ClientContext.getInstance().getCurrentAuctionId();
+ if (auctionId >= 0) {
+ loadHistory(auctionId);
+ }
+ }
+
+ @FXML
+ private void handleBackToDetail() {
+ try {
+ ClientContext.getInstance().getViewLoader().loadView("auction_detail.fxml");
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/src/main/java/com/auction/client/controllers/AuctionDetailController.java b/src/main/java/com/auction/client/controllers/AuctionDetailController.java
index d107776..35f8931 100644
--- a/src/main/java/com/auction/client/controllers/AuctionDetailController.java
+++ b/src/main/java/com/auction/client/controllers/AuctionDetailController.java
@@ -12,6 +12,8 @@
import javafx.scene.control.TextField;
import javafx.animation.PauseTransition;
import javafx.animation.ScaleTransition;
+import javafx.animation.KeyFrame;
+import javafx.animation.Timeline;
import javafx.util.Duration;
import javafx.stage.Popup;
import com.auction.client.service.ThumbnailExecutor;
@@ -24,49 +26,224 @@ public class AuctionDetailController {
@FXML private Label auctionTitleLabel;
@FXML private Label auctionDescriptionLabel;
@FXML private Label currentBidLabel;
+ @FXML private Label minIncrementPercentLabel;
+ @FXML private Label nextMinimumBidLabel;
+ @FXML private Label finalSaleLabel;
+ @FXML private Label timeContextLabel;
@FXML private Label timeLeftLabel;
+ @FXML private Label endTimestampLabel;
@FXML private Label highestBidderLabel;
+ @FXML private Label recentBidsTitleLabel;
+ @FXML private Label recentBidsSubtitleLabel;
@FXML private Button placeBidButton;
+ @FXML private Button backButton;
+ @FXML private javafx.scene.layout.HBox reconnectBanner;
+ @FXML private Label reconnectLabel;
private PollingService pollingService;
private int currentAuctionId = -1;
private com.auction.shared.models.AuctionItem currentItem;
@FXML private TextField bidAmountField;
+ @FXML private Label inlineNextMinLabel;
@FXML private Label bidStatusLabel;
@FXML private ProgressIndicator bidSpinner;
@FXML private javafx.scene.image.ImageView heroImageView;
@FXML private javafx.scene.image.ImageView thumb1View;
@FXML private javafx.scene.image.ImageView thumb2View;
@FXML private javafx.scene.image.ImageView thumb3View;
+ @FXML private javafx.scene.control.TableView recentBidsTable;
+ // initialize recent bids table cell factories only once to avoid repeated resets
+ private volatile boolean recentBidsColumnsInitialized = false;
+ // prevent overlapping network refreshes for recent bids
+ private final java.util.concurrent.atomic.AtomicBoolean recentBidsRefreshInFlight = new java.util.concurrent.atomic.AtomicBoolean(false);
+ @FXML private ProgressIndicator thumb2Spinner;
+ @FXML private ProgressIndicator thumb3Spinner;
private final java.util.concurrent.Executor executor = java.util.concurrent.Executors.newCachedThreadPool();
private static final javafx.scene.image.Image PLACEHOLDER_IMAGE = loadPlaceholderImage();
+ private volatile long serverClockOffsetMillis = 0L;
+ private Timeline countdownTimeline;
+ // lazy thumbnail prefetch controls
+ @SuppressWarnings("unused")
+ private volatile boolean thumb2Loaded = false;
+ @SuppressWarnings("unused")
+ private volatile boolean thumb3Loaded = false;
+ private PauseTransition thumb2HoverTimer;
+ private PauseTransition thumb3HoverTimer;
+ // track previous end-time to detect snipe extensions
+ private volatile String prevEndTimeIso = null;
+ private String returnViewName;
+ // ensure we only notify the user once when an auction ends
+ private volatile boolean endNotificationShown = false;
@FXML
public void initialize() {
- // nothing; detail view will be initialized via loadAuction(int)
+ updateBackButtonLabel();
+ // debug log to show what return target values are when the detail view initializes
+ try {
+ System.out.println("[RTDAS] AuctionDetail.initialize: returnViewName=" + this.returnViewName + ", ClientContext.previousViewName=" + ClientContext.getInstance().getPreviousViewName());
+ } catch (Exception ignored) {}
+
+ try {
+ int selectedAuctionId = ClientContext.getInstance().getCurrentAuctionId();
+ if (selectedAuctionId >= 0) {
+ loadAuction(selectedAuctionId);
+ }
+ } catch (Exception ignored) {}
}
public void loadAuction(int auctionId) {
this.currentAuctionId = auctionId;
- try {
- var service = ClientContext.getInstance().getRmiProvider().getService();
- this.pollingService = new PollingService(service);
- // load initial item state
- AuctionItem initial = service.getAuctionById(auctionId);
- this.currentItem = initial;
- Platform.runLater(() -> updateUi(initial));
- // load thumbnails for detail view
- loadDetailThumbnail(auctionId, 0, heroImageView);
- loadDetailThumbnail(auctionId, 1, thumb1View);
- loadDetailThumbnail(auctionId, 2, thumb2View);
- loadDetailThumbnail(auctionId, 3, thumb3View);
- pollingService.startPolling(auctionId, item -> {
- this.currentItem = item;
- Platform.runLater(() -> updateUi(item));
- });
- } catch (Exception e) {
- e.printStackTrace();
- }
+ this.endNotificationShown = false;
+ updateBackButtonLabel();
+ Platform.runLater(() -> {
+ if (auctionTitleLabel != null) auctionTitleLabel.setText("Loading auction...");
+ if (auctionDescriptionLabel != null) auctionDescriptionLabel.setText("Loading details...");
+ if (currentBidLabel != null) currentBidLabel.setText("--");
+ if (minIncrementPercentLabel != null) minIncrementPercentLabel.setText("--");
+ if (nextMinimumBidLabel != null) nextMinimumBidLabel.setText("Next: --");
+ if (finalSaleLabel != null) {
+ finalSaleLabel.setText("");
+ finalSaleLabel.setVisible(false);
+ finalSaleLabel.setManaged(false);
+ }
+ if (inlineNextMinLabel != null) {
+ inlineNextMinLabel.setText("");
+ inlineNextMinLabel.setVisible(false);
+ inlineNextMinLabel.setManaged(false);
+ }
+ if (highestBidderLabel != null) highestBidderLabel.setText("Loading...");
+ if (timeLeftLabel != null) timeLeftLabel.setText("--:--");
+ // keep the image area empty until the thumbnail fetch resolves
+ if (heroImageView != null) {
+ heroImageView.setImage(null);
+ heroImageView.setVisible(false);
+ heroImageView.setManaged(false);
+ }
+ if (thumb1View != null) {
+ thumb1View.setImage(null);
+ thumb1View.setVisible(false);
+ thumb1View.setManaged(false);
+ }
+ if (thumb2View != null) {
+ thumb2View.setImage(null);
+ thumb2View.setVisible(false);
+ thumb2View.setManaged(false);
+ }
+ if (thumb3View != null) {
+ thumb3View.setImage(null);
+ thumb3View.setVisible(false);
+ thumb3View.setManaged(false);
+ }
+ });
+
+ java.util.concurrent.CompletableFuture.supplyAsync(() -> {
+ try {
+ var service = ClientContext.getInstance().getRmiProvider().getService();
+ AuctionItem initial = service.getAuctionById(auctionId);
+ return new java.util.AbstractMap.SimpleEntry<>(service, initial);
+ } catch (Exception e) {
+ throw new java.util.concurrent.CompletionException(e);
+ }
+ }, executor).whenComplete((result, throwable) -> {
+ if (throwable != null || result == null) {
+ Platform.runLater(() -> {
+ if (auctionDescriptionLabel != null) {
+ auctionDescriptionLabel.setText("Unable to load auction details.");
+ }
+ showReconnectBanner(throwable);
+ });
+ return;
+ }
+
+ var service = result.getKey();
+ AuctionItem initial = result.getValue();
+
+ try {
+ this.pollingService = new PollingService(service);
+ syncServerClockOffset(service);
+ this.currentItem = initial;
+ this.prevEndTimeIso = initial == null ? null : initial.getEndTime();
+
+ Platform.runLater(() -> {
+ updateUi(initial);
+ hideReconnectBanner();
+ startCountdownTicker();
+ });
+
+ // show all available images as explicit rail slots and include primary image in the rail
+ // hero = image 0, thumb1 = image 0, thumb2 = image 1, thumb3 = image 2
+ loadDetailThumbnail(auctionId, 0, heroImageView);
+ if (thumb1View != null) {
+ thumb1View.setPickOnBounds(true);
+ thumb1View.setCursor(javafx.scene.Cursor.HAND);
+ }
+ if (thumb2View != null) {
+ thumb2View.setPickOnBounds(true);
+ thumb2View.setCursor(javafx.scene.Cursor.HAND);
+ }
+ if (thumb3View != null) {
+ thumb3View.setPickOnBounds(true);
+ thumb3View.setCursor(javafx.scene.Cursor.HAND);
+ }
+ // load rail: primary image first, then subsequent images
+ loadDetailThumbnail(auctionId, 0, thumb1View);
+ loadDetailThumbnail(auctionId, 1, thumb2View);
+ loadDetailThumbnail(auctionId, 2, thumb3View);
+
+ pollingService.startPolling(auctionId, item -> {
+ // capture prior end-time to detect extensions
+ String priorEnd = this.currentItem == null ? this.prevEndTimeIso : this.currentItem.getEndTime();
+ this.currentItem = item;
+ Platform.runLater(() -> {
+ syncServerClockOffset(service);
+ updateUi(item);
+ // refresh recent bids list on each poll
+ refreshRecentBids();
+ hideReconnectBanner();
+ // if end time was extended on the server, notify user
+ try {
+ if (priorEnd != null && item != null && item.getEndTime() != null) {
+ java.time.Instant prior = java.time.Instant.parse(priorEnd);
+ java.time.Instant nowEnd = java.time.Instant.parse(item.getEndTime());
+ if (nowEnd.isAfter(prior)) {
+ showToast("Timer extended!");
+ highlightCountdownExtension();
+ }
+ }
+ } catch (Exception ignored) {}
+ // update prevEndTimeIso
+ prevEndTimeIso = item == null ? null : item.getEndTime();
+ });
+ }, t -> {
+ // show reconnect banner when repeated failures occur
+ Platform.runLater(() -> showReconnectBanner(t));
+ });
+
+ // initial load of recent bids
+ refreshRecentBids();
+
+ // Pause/resume polling when window focus changes to reduce load
+ Platform.runLater(() -> {
+ try {
+ if (heroImageView != null && heroImageView.getScene() != null && heroImageView.getScene().getWindow() != null) {
+ var window = heroImageView.getScene().getWindow();
+ window.focusedProperty().addListener((obs, oldV, newV) -> {
+ if (pollingService != null) {
+ if (Boolean.FALSE.equals(newV)) {
+ pollingService.pause();
+ } else {
+ pollingService.resume();
+ }
+ }
+ });
+ }
+ } catch (Exception ignored) {}
+ });
+ } catch (Exception e) {
+ e.printStackTrace();
+ Platform.runLater(() -> showReconnectBanner(e));
+ }
+ });
}
private void updateUi(AuctionItem item) {
@@ -81,17 +258,153 @@ private void updateUi(AuctionItem item) {
auctionDescriptionLabel.setText(desc);
}
currentBidLabel.setText(com.auction.shared.Constants.formatCents(item.getCurrentBidCents()));
+ updateMinimumBidLabels(item);
highestBidderLabel.setText(item.getHighestBidderUsername() == null ? "N/A" : item.getHighestBidderUsername());
- if (timeLeftLabel != null) {
- timeLeftLabel.setText(formatTimeLeft(item.getEndTime()));
+ updateCountdownLabel(item);
+ updateRecentBidsHeader(item);
+ // Disable bid UI when auction is not ACTIVE
+ try {
+ boolean active = com.auction.shared.Constants.STATUS_ACTIVE.equals(item.getStatus());
+ if (placeBidButton != null) placeBidButton.setDisable(!active);
+ if (bidAmountField != null) bidAmountField.setDisable(!active);
+ // also update bidder controls visibility if present
+ if (placeBidButton != null && bidStatusLabel != null) {
+ if (!active) {
+ if (com.auction.shared.Constants.STATUS_SCHEDULED.equals(item.getStatus())) {
+ bidStatusLabel.setText("Auction has not started yet");
+ } else {
+ bidStatusLabel.setText("Bidding closed");
+ }
+ } else {
+ // clear status when active
+ if (bidStatusLabel.getText() != null && (bidStatusLabel.getText().contains("Bidding closed") || bidStatusLabel.getText().contains("not started"))) {
+ bidStatusLabel.setText("");
+ }
+ // reset end-notification when auction becomes active again
+ endNotificationShown = false;
+ }
+ }
+ } catch (Exception ignored) {}
+ }
+
+ private void updateRecentBidsHeader(AuctionItem item) {
+ if (item == null) return;
+ boolean sold = com.auction.shared.Constants.STATUS_SOLD.equals(item.getStatus());
+ if (recentBidsTitleLabel != null) {
+ recentBidsTitleLabel.setText(sold ? "Bid History (read-only)" : "Bid History");
+ }
+ if (recentBidsSubtitleLabel != null) {
+ recentBidsSubtitleLabel.setText(sold
+ ? "Historical bids for this sold auction are shown below and cannot be modified."
+ : "Chronological history for the selected auction.");
+ }
+ if (recentBidsTable != null) {
+ recentBidsTable.setEditable(false);
+ recentBidsTable.setFocusTraversable(false);
+ }
+ }
+
+ private void updateMinimumBidLabels(AuctionItem item) {
+ if (item == null) return;
+
+ double percent = item.getMinIncrementPercent();
+ if (percent <= 0d) {
+ percent = com.auction.shared.Constants.MIN_BID_INCREMENT_PERCENT;
+ }
+
+ // Always show the configured percentage (or default) for all statuses
+ if (minIncrementPercentLabel != null) {
+ minIncrementPercentLabel.setText(String.format("%.1f%%", percent * 100.0));
+ minIncrementPercentLabel.setVisible(true);
+ minIncrementPercentLabel.setManaged(true);
+ }
+
+ // Next-minimum logic varies by status, but the inline helper should stay visible
+ // for any non-sold auction so the place-bid panel always shows a reference amount.
+ String status = item.getStatus();
+ long referenceBase = Math.max(0L, item.getCurrentBidCents());
+ String inlinePrefix = "Minimum bid: ";
+ if (com.auction.shared.Constants.STATUS_ACTIVE.equals(status)) {
+ long nextMinimumBid;
+ if (item.getHighestBidderUsername() == null) {
+ // First bid may start at the auction's opening price.
+ nextMinimumBid = Math.max(0L, item.getStartingPriceCents());
+ inlinePrefix = "Starting bid: ";
+ } else {
+ long incrementCents = Math.max(1L, Math.round(referenceBase * percent));
+ nextMinimumBid = referenceBase + incrementCents;
+ }
+ if (nextMinimumBidLabel != null) {
+ nextMinimumBidLabel.setText("Next: " + com.auction.shared.Constants.formatCents(nextMinimumBid));
+ nextMinimumBidLabel.setVisible(true);
+ nextMinimumBidLabel.setManaged(true);
+ }
+ } else if (com.auction.shared.Constants.STATUS_SCHEDULED.equals(status)) {
+ referenceBase = Math.max(0L, item.getStartingPriceCents());
+ inlinePrefix = "Starts at: ";
+ long incrementCents = Math.max(1L, Math.round(referenceBase * percent));
+ long nextMinimumBid = referenceBase + incrementCents;
+ if (nextMinimumBidLabel != null) {
+ nextMinimumBidLabel.setText("Next: " + com.auction.shared.Constants.formatCents(nextMinimumBid));
+ nextMinimumBidLabel.setVisible(true);
+ nextMinimumBidLabel.setManaged(true);
+ }
+ } else {
+ if (nextMinimumBidLabel != null) {
+ nextMinimumBidLabel.setText("");
+ nextMinimumBidLabel.setVisible(false);
+ nextMinimumBidLabel.setManaged(false);
+ }
+ }
+
+ if (!com.auction.shared.Constants.STATUS_SOLD.equals(status) && inlineNextMinLabel != null) {
+ long referenceNext;
+ if (com.auction.shared.Constants.STATUS_ACTIVE.equals(status) && item.getHighestBidderUsername() == null) {
+ referenceNext = Math.max(0L, item.getStartingPriceCents());
+ } else {
+ long referenceIncrement = Math.max(1L, Math.round(referenceBase * percent));
+ referenceNext = referenceBase + referenceIncrement;
+ }
+ inlineNextMinLabel.setText(inlinePrefix + com.auction.shared.Constants.formatCents(referenceNext));
+ inlineNextMinLabel.setVisible(true);
+ inlineNextMinLabel.setManaged(true);
+ } else if (inlineNextMinLabel != null) {
+ inlineNextMinLabel.setText("");
+ inlineNextMinLabel.setVisible(false);
+ inlineNextMinLabel.setManaged(false);
+ }
+
+ // Final sale handling: show sold price if status is SOLD
+ if (com.auction.shared.Constants.STATUS_SOLD.equals(item.getStatus())) {
+ if (finalSaleLabel != null) {
+ finalSaleLabel.setText("Sold for " + com.auction.shared.Constants.formatCents(item.getCurrentBidCents()));
+ finalSaleLabel.setVisible(true);
+ finalSaleLabel.setManaged(true);
+ }
+ // hide nextMinimum label when sold
+ if (nextMinimumBidLabel != null) {
+ nextMinimumBidLabel.setText("");
+ nextMinimumBidLabel.setVisible(false);
+ nextMinimumBidLabel.setManaged(false);
+ }
+ } else {
+ if (finalSaleLabel != null) {
+ finalSaleLabel.setText("");
+ finalSaleLabel.setVisible(false);
+ finalSaleLabel.setManaged(false);
+ }
}
}
private String formatTimeLeft(String endTimeIso) {
+ return formatTimeLeft(java.time.Instant.now().plusMillis(serverClockOffsetMillis), endTimeIso);
+ }
+
+ static String formatTimeLeft(java.time.Instant adjustedNow, String endTimeIso) {
if (endTimeIso == null || endTimeIso.isBlank()) return "--:--";
try {
java.time.Instant end = java.time.Instant.parse(endTimeIso);
- java.time.Duration d = java.time.Duration.between(java.time.Instant.now(), end);
+ java.time.Duration d = java.time.Duration.between(adjustedNow, end);
if (d.isNegative() || d.isZero()) return "Ended";
long hours = d.toHours();
long minutes = d.minusHours(hours).toMinutes();
@@ -105,8 +418,171 @@ private String formatTimeLeft(String endTimeIso) {
}
}
+ private void updateCountdownLabel(AuctionItem item) {
+ if (timeLeftLabel == null || item == null) return;
+ if (com.auction.shared.Constants.STATUS_SCHEDULED.equals(item.getStatus())) {
+ if (timeContextLabel != null) timeContextLabel.setText("Starts In");
+ String startIso = item.getStartTime();
+ if (startIso == null || startIso.isBlank()) {
+ timeLeftLabel.setText("Awaiting manual start");
+ } else {
+ String startsIn = formatTimeLeft(startIso);
+ if ("Ended".equals(startsIn)) {
+ timeLeftLabel.setText("Awaiting manual start");
+ } else {
+ timeLeftLabel.setText(startsIn);
+ }
+ }
+ } else {
+ if (timeContextLabel != null) timeContextLabel.setText("Time Left");
+ String formatted = formatTimeLeft(item.getEndTime());
+ if ("Ended".equals(formatted)) {
+ // show ended indicator and timestamp
+ timeLeftLabel.setText("Ended");
+ try {
+ if (endTimestampLabel != null && item.getEndTime() != null && !item.getEndTime().isBlank()) {
+ java.time.Instant end = java.time.Instant.parse(item.getEndTime());
+ java.time.ZonedDateTime zdt = java.time.ZonedDateTime.ofInstant(end, java.time.ZoneId.systemDefault());
+ java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z");
+ endTimestampLabel.setText("Ended at: " + zdt.format(fmt));
+ endTimestampLabel.setVisible(true);
+ endTimestampLabel.setManaged(true);
+ }
+ } catch (Exception ignored) {
+ if (endTimestampLabel != null) {
+ endTimestampLabel.setVisible(false);
+ endTimestampLabel.setManaged(false);
+ }
+ }
+ // notify once when auction transitions to ended
+ try {
+ if (!endNotificationShown && item != null) {
+ endNotificationShown = true;
+ String winner = item.getHighestBidderUsername();
+ long price = item.getCurrentBidCents();
+ if (winner == null || winner.isBlank()) {
+ Alert alert = new Alert(Alert.AlertType.INFORMATION);
+ alert.setTitle("Auction Ended");
+ alert.setHeaderText("Auction ended — no bids were placed");
+ alert.setContentText("This auction closed without any bids.");
+ alert.showAndWait();
+ if (bidStatusLabel != null) bidStatusLabel.setText("Auction ended — no winner");
+ } else {
+ String msg = "Auction ended! Congratulations! user " + winner + " has won the auction at " + com.auction.shared.Constants.formatCents(price);
+ Alert alert = new Alert(Alert.AlertType.INFORMATION);
+ alert.setTitle("Auction Ended");
+ alert.setHeaderText("Auction ended — Winner: " + winner);
+ alert.setContentText("Sold for " + com.auction.shared.Constants.formatCents(price));
+ alert.showAndWait();
+ if (bidStatusLabel != null) bidStatusLabel.setText("Auction ended — Winner: " + winner + " at " + com.auction.shared.Constants.formatCents(price));
+ }
+ }
+ } catch (Exception ignored) {}
+ } else {
+ timeLeftLabel.setText(formatted);
+ if (endTimestampLabel != null) {
+ endTimestampLabel.setVisible(false);
+ endTimestampLabel.setManaged(false);
+ }
+ }
+ }
+ updateCountdownVisuals(item);
+ }
+
+ private void updateCountdownVisuals(AuctionItem item) {
+ try {
+ if (timeLeftLabel == null || item == null) return;
+ long remainingSeconds;
+ if (com.auction.shared.Constants.STATUS_SCHEDULED.equals(item.getStatus()) && item.getStartTime() != null && !item.getStartTime().isBlank()) {
+ java.time.Instant start = java.time.Instant.parse(item.getStartTime());
+ remainingSeconds = java.time.Duration.between(java.time.Instant.now().plusMillis(serverClockOffsetMillis), start).getSeconds();
+ } else {
+ if (item.getEndTime() == null) return;
+ java.time.Instant end = java.time.Instant.parse(item.getEndTime());
+ remainingSeconds = java.time.Duration.between(java.time.Instant.now().plusMillis(serverClockOffsetMillis), end).getSeconds();
+ }
+ // update style classes for visual states
+ setCountdownStateClass(remainingSeconds);
+ // update accessibility tooltip + accessible text
+ try {
+ String tooltipText = remainingSeconds <= 0 ? "Ended" : formatTimeLeft(item.getEndTime());
+ javafx.scene.control.Tooltip tooltip = new javafx.scene.control.Tooltip(tooltipText);
+ try { javafx.scene.control.Tooltip.install(timeLeftLabel, tooltip); } catch (Exception ignored) {}
+ timeLeftLabel.setAccessibleText(tooltipText);
+ } catch (Exception ignored) {}
+ } catch (Exception ignored) {}
+ }
+
+ private void setCountdownStateClass(long remainingSeconds) {
+ try {
+ if (timeLeftLabel == null) return;
+ timeLeftLabel.getStyleClass().removeAll("countdown-normal", "countdown-warning", "countdown-urgent", "countdown-ended");
+ if (remainingSeconds <= 0) {
+ timeLeftLabel.getStyleClass().add("countdown-ended");
+ timeLeftLabel.setAccessibleText("Time left: Ended");
+ } else if (remainingSeconds <= 30) {
+ timeLeftLabel.getStyleClass().add("countdown-urgent");
+ timeLeftLabel.setAccessibleText("Time left: less than 30 seconds");
+ } else if (remainingSeconds <= 60) {
+ timeLeftLabel.getStyleClass().add("countdown-warning");
+ timeLeftLabel.setAccessibleText("Time left: less than one minute");
+ } else {
+ timeLeftLabel.getStyleClass().add("countdown-normal");
+ timeLeftLabel.setAccessibleText("Time left: more than one minute");
+ }
+ // Tooltip for screen readers and mouse users
+ try {
+ javafx.scene.control.Tooltip tooltip = new javafx.scene.control.Tooltip(timeLeftLabel.getAccessibleText());
+ javafx.scene.control.Tooltip.install(timeLeftLabel, tooltip);
+ } catch (Exception ignored) {}
+ } catch (Exception ignored) {}
+ }
+
+ private void highlightCountdownExtension() {
+ try {
+ if (timeLeftLabel == null) return;
+ // brief scale + glow animation to draw attention
+ ScaleTransition st = new ScaleTransition(Duration.millis(220), timeLeftLabel);
+ st.setFromX(1.0);
+ st.setFromY(1.0);
+ st.setToX(1.08);
+ st.setToY(1.08);
+ st.setAutoReverse(true);
+ st.setCycleCount(2);
+ st.play();
+ } catch (Exception ignored) {}
+ }
+
+ private void startCountdownTicker() {
+ if (countdownTimeline != null) {
+ countdownTimeline.stop();
+ }
+ countdownTimeline = new Timeline(new KeyFrame(Duration.seconds(1), evt -> {
+ if (currentItem != null) {
+ updateCountdownLabel(currentItem);
+ }
+ }));
+ countdownTimeline.setCycleCount(Timeline.INDEFINITE);
+ countdownTimeline.play();
+ }
+
+ private void syncServerClockOffset(com.auction.shared.interfaces.IAuctionService service) {
+ try {
+ String serverTimeIso = service.serverTime();
+ if (serverTimeIso != null && !serverTimeIso.isBlank()) {
+ java.time.Instant serverInstant = java.time.Instant.parse(serverTimeIso);
+ serverClockOffsetMillis = serverInstant.toEpochMilli() - System.currentTimeMillis();
+ }
+ } catch (Exception ignored) {
+ // keep the last known offset if the server time request fails
+ }
+ }
+
public void shutdown() {
if (pollingService != null) pollingService.shutdown();
+ if (countdownTimeline != null) countdownTimeline.stop();
+ try { if (thumb2HoverTimer != null) thumb2HoverTimer.stop(); } catch (Exception ignored) {}
+ try { if (thumb3HoverTimer != null) thumb3HoverTimer.stop(); } catch (Exception ignored) {}
}
@FXML
@@ -129,6 +605,20 @@ private void handlePlaceBid() {
long amountCents = Math.round(amount * 100);
long expected = currentItem == null ? 0L : currentItem.getCurrentBidCents();
+ // Show an immediate response for the common self-bid case before the server round-trip.
+ try {
+ String username = ClientContext.getInstance().getUsername();
+ if (currentItem != null && username != null && username.equals(currentItem.getSellerUsername())) {
+ bidStatusLabel.setText("You cannot bid on your own auction.");
+ Alert alert = new Alert(Alert.AlertType.INFORMATION);
+ alert.setTitle("Invalid Bid");
+ alert.setHeaderText("Cannot bid on your own item");
+ alert.setContentText("You are the seller of this auction.");
+ alert.showAndWait();
+ return;
+ }
+ } catch (Exception ignored) {}
+
// optimistic UI update
long prevBid = currentItem == null ? 0L : currentItem.getCurrentBidCents();
String prevHighest = currentItem == null ? null : currentItem.getHighestBidderUsername();
@@ -160,13 +650,24 @@ private void handlePlaceBid() {
showToast("Bid placed");
animateSuccess();
});
+ try {
+ AuctionItem refreshed = service.getAuctionById(currentAuctionId);
+ if (refreshed != null) {
+ currentItem = refreshed;
+ Platform.runLater(() -> updateUi(refreshed));
+ }
+ } catch (Exception refreshError) {
+ refreshError.printStackTrace();
+ }
} catch (Exception e) {
- // parse server-side AuctionException if present
+ // parse server-side AuctionException if present and show Alerts for parity with docs
String userMsg = "Failed to place bid";
Throwable cause = e;
+ com.auction.shared.exceptions.AuctionException foundEx = null;
while (cause != null) {
if (cause instanceof com.auction.shared.exceptions.AuctionException) {
- userMsg = cause.getMessage();
+ foundEx = (com.auction.shared.exceptions.AuctionException) cause;
+ userMsg = foundEx.getMessage();
break;
}
cause = cause.getCause();
@@ -177,10 +678,61 @@ private void handlePlaceBid() {
currentItem.setCurrentBidCents(prevBid);
currentItem.setHighestBidderUsername(prevHighest);
}
- Platform.runLater(() -> {
- updateUi(currentItem);
- bidStatusLabel.setText("Failed: " + finalMsg);
- });
+ // Decide whether to show an Alert dialog for parity with docs
+ if (foundEx != null) {
+ com.auction.shared.exceptions.AuctionException ex = foundEx;
+ if (ex instanceof com.auction.shared.exceptions.StaleDataException) {
+ Platform.runLater(() -> {
+ updateUi(currentItem);
+ bidStatusLabel.setText("Failed: " + finalMsg);
+ javafx.scene.control.Alert a = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.WARNING);
+ a.setTitle("Bid Out Of Date");
+ a.setHeaderText("Price changed on server");
+ a.setContentText(finalMsg + " Please refresh and try again.");
+ a.showAndWait();
+ });
+ } else if (ex instanceof com.auction.shared.exceptions.SelfBidException) {
+ Platform.runLater(() -> {
+ updateUi(currentItem);
+ bidStatusLabel.setText("Failed: " + finalMsg);
+ javafx.scene.control.Alert a = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.INFORMATION);
+ a.setTitle("Invalid Bid");
+ a.setHeaderText("Cannot bid on your own item");
+ a.setContentText(finalMsg);
+ a.showAndWait();
+ });
+ } else if (ex instanceof com.auction.shared.exceptions.DuplicateBidException) {
+ Platform.runLater(() -> {
+ updateUi(currentItem);
+ bidStatusLabel.setText("Failed: " + finalMsg);
+ javafx.scene.control.Alert a = new javafx.scene.control.Alert(javafx.scene.control.Alert.AlertType.INFORMATION);
+ a.setTitle("Already Highest Bidder");
+ a.setHeaderText("You're already the highest bidder");
+ a.setContentText(finalMsg);
+ a.showAndWait();
+ });
+ } else {
+ Platform.runLater(() -> {
+ updateUi(currentItem);
+ bidStatusLabel.setText("Failed: " + finalMsg);
+ Alert a = new Alert(Alert.AlertType.ERROR);
+ a.setTitle("Bid Failed");
+ a.setHeaderText("Bid could not be placed");
+ a.setContentText(finalMsg);
+ a.showAndWait();
+ });
+ }
+ } else {
+ Platform.runLater(() -> {
+ updateUi(currentItem);
+ bidStatusLabel.setText("Failed: " + finalMsg);
+ Alert a = new Alert(Alert.AlertType.ERROR);
+ a.setTitle("Bid Failed");
+ a.setHeaderText("Bid could not be placed");
+ a.setContentText(finalMsg);
+ a.showAndWait();
+ });
+ }
} finally {
Platform.runLater(() -> {
placeBidButton.setDisable(false);
@@ -192,10 +744,21 @@ private void handlePlaceBid() {
}
private void loadDetailThumbnail(int auctionId, int index, javafx.scene.image.ImageView target) {
+ Platform.runLater(() -> {
+ if (target != null) {
+ target.setImage(null);
+ target.setVisible(false);
+ target.setManaged(false);
+ }
+ });
+
java.util.concurrent.CompletableFuture.supplyAsync(() -> {
try {
var service = ClientContext.getInstance().getRmiProvider().getService();
byte[] bytes = service.getThumbnail(auctionId, index);
+ if (bytes == null || bytes.length == 0) {
+ bytes = service.getFullImage(auctionId, index);
+ }
if (bytes == null || bytes.length == 0) return null;
return new javafx.scene.image.Image(new java.io.ByteArrayInputStream(bytes));
} catch (Exception e) {
@@ -206,42 +769,127 @@ private void loadDetailThumbnail(int auctionId, int index, javafx.scene.image.Im
if (image != null) {
Platform.runLater(() -> {
target.setImage(image);
+ target.setVisible(true);
+ target.setManaged(true);
target.setStyle(null);
});
} else {
Platform.runLater(() -> {
target.setImage(PLACEHOLDER_IMAGE);
+ target.setVisible(true);
+ target.setManaged(true);
target.setStyle(null);
});
}
+ // mark loaded flags for lazy thumbs
+ try {
+ if (index == 2) thumb2Loaded = true;
+ if (index == 3) thumb3Loaded = true;
+ } catch (Exception ignored) {}
});
}
@FXML
private void handleThumb1Click(javafx.scene.input.MouseEvent e) {
- if (thumb1View.getImage() != null) heroImageView.setImage(thumb1View.getImage());
+ if (thumb1View.getImage() != null && thumb1View.getImage() != PLACEHOLDER_IMAGE) {
+ heroImageView.setImage(thumb1View.getImage());
+ heroImageView.setVisible(true);
+ heroImageView.setManaged(true);
+ return;
+ }
+ promoteThumbnailToHero(0);
}
@FXML
private void handleThumb2Click(javafx.scene.input.MouseEvent e) {
- if (thumb2View.getImage() != null) heroImageView.setImage(thumb2View.getImage());
+ if (thumb2View.getImage() != null && thumb2View.getImage() != PLACEHOLDER_IMAGE) {
+ heroImageView.setImage(thumb2View.getImage());
+ heroImageView.setVisible(true);
+ heroImageView.setManaged(true);
+ return;
+ }
+ promoteThumbnailToHero(1);
}
@FXML
private void handleThumb3Click(javafx.scene.input.MouseEvent e) {
- if (thumb3View.getImage() != null) heroImageView.setImage(thumb3View.getImage());
+ if (thumb3View.getImage() != null && thumb3View.getImage() != PLACEHOLDER_IMAGE) {
+ heroImageView.setImage(thumb3View.getImage());
+ heroImageView.setVisible(true);
+ heroImageView.setManaged(true);
+ return;
+ }
+ promoteThumbnailToHero(2);
}
// allow gallery to request showing a particular hero index after loading
public void showHeroImageIndex(int index) {
+ try {
+ System.out.println("[RTDAS][Detail] showHeroImageIndex requested index=" + index + ", currentAuctionId=" + this.currentAuctionId + ", returnViewName=" + this.returnViewName);
+ } catch (Exception ignored) {}
+ if (heroImageView == null) return;
switch (index) {
- case 0: if (heroImageView.getImage() != null) heroImageView.setImage(heroImageView.getImage()); break;
- case 1: if (thumb1View.getImage() != null) heroImageView.setImage(thumb1View.getImage()); break;
- case 2: if (thumb2View.getImage() != null) heroImageView.setImage(thumb2View.getImage()); break;
- default: break;
+ case 0:
+ promoteThumbnailToHero(0);
+ break;
+ case 1:
+ promoteThumbnailToHero(1);
+ break;
+ case 2:
+ promoteThumbnailToHero(2);
+ break;
+ default:
+ promoteThumbnailToHero(0);
+ break;
}
}
+ private void promoteThumbnailToHero(int index) {
+ try {
+ System.out.println("[RTDAS][Detail] promoteThumbnailToHero called with index=" + index + ", currentAuctionId=" + this.currentAuctionId);
+ } catch (Exception ignored) {}
+ // map requested image index to the rail image view so promotion uses the rail slots
+ javafx.scene.image.ImageView targetThumb = switch (index) {
+ case 0 -> thumb1View;
+ case 1 -> thumb2View;
+ case 2 -> thumb3View;
+ default -> null;
+ };
+
+ try {
+ String tname = targetThumb == thumb1View ? "thumb1View" : targetThumb == thumb2View ? "thumb2View" : targetThumb == thumb3View ? "thumb3View" : "null";
+ System.out.println("[RTDAS][Detail] promoteThumbnailToHero mapping: index=" + index + ", target=" + tname);
+ } catch (Exception ignored) {}
+ if (targetThumb == null || heroImageView == null) return;
+
+ if (targetThumb.getImage() != null && targetThumb.getImage() != PLACEHOLDER_IMAGE) {
+ heroImageView.setImage(targetThumb.getImage());
+ heroImageView.setVisible(true);
+ heroImageView.setManaged(true);
+ return;
+ }
+
+ // If the source image has not loaded yet, load it now and promote it into the hero once available.
+ loadDetailThumbnail(currentAuctionId, index, targetThumb);
+ java.util.concurrent.CompletableFuture.runAsync(() -> {
+ long waited = 0L;
+ while (waited < 2500) {
+ try { Thread.sleep(100); } catch (InterruptedException ignored) {}
+ waited += 100;
+ if (targetThumb.getImage() != null && targetThumb.getImage() != PLACEHOLDER_IMAGE) {
+ break;
+ }
+ }
+ Platform.runLater(() -> {
+ if (targetThumb.getImage() != null && targetThumb.getImage() != PLACEHOLDER_IMAGE) {
+ heroImageView.setImage(targetThumb.getImage());
+ heroImageView.setVisible(true);
+ heroImageView.setManaged(true);
+ }
+ });
+ }, executor);
+ }
+
private void animateSuccess() {
if (heroImageView == null) return;
ScaleTransition st = new ScaleTransition(Duration.millis(300), heroImageView);
@@ -290,16 +938,187 @@ private void handleRelistAuction() {
@FXML
private void handleBackToGallery() {
+ shutdown();
+ ClientContext context = ClientContext.getInstance();
+ // defensive logging to help debug runtime issues reported by users
+ System.out.println("[RTDAS] AuctionDetail.handleBackToGallery called; previousViewName=" + ClientContext.getInstance().getPreviousViewName() + ", returnViewName=" + this.returnViewName);
+ String targetView = resolveTargetViewName();
+ if (targetView == null || targetView.isBlank()) {
+ targetView = "gallery.fxml";
+ }
+ context.setCurrentAuctionId(-1);
+ context.setPreviousViewName(targetView);
+ System.out.println("[RTDAS] AuctionDetail navigating back to: " + targetView);
+ String finalTargetView = targetView;
+ Platform.runLater(() -> {
+ try {
+ context.getViewLoader().loadView(finalTargetView);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+ }
+
+ @FXML
+ private void handleOpenBidHistoryPage() {
try {
- shutdown();
ClientContext context = ClientContext.getInstance();
- String targetView = context.getPreviousViewName();
- if (targetView == null || targetView.isBlank()) {
- targetView = "gallery.fxml";
- }
- context.getViewLoader().loadView(targetView);
+ context.getViewLoader().loadView("auction_bid_history.fxml");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
+
+ /** Load recent bids asynchronously and update the `recentBidsTable`. */
+ private void refreshRecentBids() {
+ if (recentBidsTable == null || currentAuctionId < 0) return;
+ // avoid overlapping refreshes
+ if (!recentBidsRefreshInFlight.compareAndSet(false, true)) return;
+ com.auction.client.service.BidHistoryService.loadBidHistoryAsync(currentAuctionId)
+ .whenComplete((list, t) -> {
+ recentBidsRefreshInFlight.set(false);
+ if (t != null) return; // ignore failures; reconnect banner handles polling errors
+ java.util.List bids = list == null ? java.util.List.of() : list;
+ // sort by amount descending then timestamp descending, limit to 10
+ final java.util.List sortedBids = bids.stream()
+ .sorted(java.util.Comparator.comparingLong(com.auction.shared.models.Bid::getAmountCents).reversed()
+ .thenComparing((b1, b2) -> {
+ try {
+ java.time.Instant i1 = java.time.Instant.parse(b1.getTimestamp());
+ java.time.Instant i2 = java.time.Instant.parse(b2.getTimestamp());
+ return i2.compareTo(i1);
+ } catch (Exception e) { return 0; }
+ }))
+ .limit(10)
+ .toList();
+
+ Platform.runLater(() -> {
+ recentBidsTable.getItems().setAll(sortedBids);
+ // initialize columns and cell factories only once to avoid UI churn
+ try {
+ if (!recentBidsColumnsInitialized && recentBidsTable.getColumns().size() >= 3) {
+ recentBidsColumnsInitialized = true;
+ @SuppressWarnings("unchecked")
+ javafx.scene.control.TableColumn timeCol = (javafx.scene.control.TableColumn) recentBidsTable.getColumns().get(0);
+ timeCol.setCellFactory(new javafx.util.Callback, javafx.scene.control.TableCell>() {
+ @Override
+ public javafx.scene.control.TableCell call(javafx.scene.control.TableColumn col) {
+ return new javafx.scene.control.TableCell() {
+ private final java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss");
+ @Override protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null) { setText(null); return; }
+ try {
+ java.time.Instant inst = java.time.Instant.parse(item);
+ java.time.ZonedDateTime zdt = java.time.ZonedDateTime.ofInstant(inst, java.time.ZoneId.systemDefault());
+ setText(zdt.format(fmt));
+ } catch (Exception e) { setText(item); }
+ }
+ };
+ }
+ });
+
+ @SuppressWarnings("unchecked")
+ javafx.scene.control.TableColumn userCol = (javafx.scene.control.TableColumn) recentBidsTable.getColumns().get(1);
+ userCol.setCellFactory(new javafx.util.Callback, javafx.scene.control.TableCell>() {
+ @Override
+ public javafx.scene.control.TableCell call(javafx.scene.control.TableColumn col) {
+ return new javafx.scene.control.TableCell() {
+ @Override protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+ setText(empty || item == null ? null : item);
+ }
+ };
+ }
+ });
+
+ @SuppressWarnings("unchecked")
+ javafx.scene.control.TableColumn amtCol = (javafx.scene.control.TableColumn) recentBidsTable.getColumns().get(2);
+ amtCol.setCellFactory(new javafx.util.Callback, javafx.scene.control.TableCell>() {
+ @Override
+ public javafx.scene.control.TableCell call(javafx.scene.control.TableColumn col) {
+ return new javafx.scene.control.TableCell() {
+ @Override protected void updateItem(Number item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null) { setText(null); return; }
+ try {
+ long cents = item.longValue();
+ setText(com.auction.shared.Constants.formatCents(cents));
+ } catch (Exception e) { setText(item.toString()); }
+ }
+ };
+ }
+ });
+ }
+ } catch (Exception ignored) {}
+ });
+ });
+ }
+
+ public void setReturnViewName(String returnViewName) {
+ this.returnViewName = returnViewName;
+ // keep shared context consistent for other callers that may read previousViewName
+ try {
+ ClientContext.getInstance().setPreviousViewName(returnViewName);
+ } catch (Exception ignored) {}
+ updateBackButtonLabel();
+ }
+
+ private String resolveTargetViewName() {
+ if (returnViewName != null && !returnViewName.isBlank()) {
+ return returnViewName;
+ }
+ String prev = ClientContext.getInstance().getPreviousViewName();
+ if (prev != null && !prev.isBlank()) return prev;
+ // defensive default: return to dashboard if nothing is set
+ return "user_dashboard.fxml";
+ }
+
+ private void updateBackButtonLabel() {
+ try {
+ if (backButton == null) return;
+ // ensure the button is visible and participates in layout
+ backButton.setVisible(true);
+ backButton.setManaged(true);
+ String targetView = resolveTargetViewName();
+ if (targetView == null || targetView.isBlank()) {
+ backButton.setText("Back");
+ return;
+ }
+
+ String normalized = targetView.toLowerCase();
+ if (normalized.contains("gallery")) {
+ backButton.setText("Back to Gallery");
+ } else if (normalized.contains("dashboard")) {
+ backButton.setText("Back to Dashboard");
+ } else {
+ backButton.setText("Back");
+ }
+ } catch (Exception ignored) {}
+ }
+
+ /**
+ * Show a reconnect banner with an optional Throwable message. Safe to call from FX thread.
+ */
+ public void showReconnectBanner(Throwable t) {
+ try {
+ if (reconnectBanner != null) {
+ String msg = "Disconnected — retrying...";
+ if (t != null && t.getMessage() != null) msg = "Disconnected — " + t.getMessage();
+ reconnectLabel.setText(msg);
+ reconnectBanner.setVisible(true);
+ reconnectBanner.setManaged(true);
+ }
+ } catch (Exception ignored) {}
+ }
+
+ /** Hide the reconnect banner. Safe to call from FX thread. */
+ public void hideReconnectBanner() {
+ try {
+ if (reconnectBanner != null) {
+ reconnectBanner.setVisible(false);
+ reconnectBanner.setManaged(false);
+ }
+ } catch (Exception ignored) {}
+ }
}
diff --git a/src/main/java/com/auction/client/controllers/ConnectController.java b/src/main/java/com/auction/client/controllers/ConnectController.java
index 0b10852..4abd3a9 100644
--- a/src/main/java/com/auction/client/controllers/ConnectController.java
+++ b/src/main/java/com/auction/client/controllers/ConnectController.java
@@ -12,9 +12,15 @@ public class ConnectController {
@FXML
public void initialize() {
com.auction.client.core.ClientContext context = com.auction.client.core.ClientContext.getInstance();
- context.getUdpClient().startListening();
+ boolean discoveryStarted = context.getUdpClient().startListening();
- javafx.application.Platform.runLater(() -> statusLabel.setText("Waiting for discovered servers. You can edit IP and port manually."));
+ javafx.application.Platform.runLater(() -> {
+ if (discoveryStarted) {
+ statusLabel.setText("Waiting for discovered servers. You can edit IP and port manually.");
+ } else {
+ statusLabel.setText("Automatic discovery unavailable here. Use manual IP and port.");
+ }
+ });
// Start a background thread to update the list view
Thread updateThread = new Thread(() -> {
diff --git a/src/main/java/com/auction/client/controllers/GalleryController.java b/src/main/java/com/auction/client/controllers/GalleryController.java
index b2442a7..9c442a4 100644
--- a/src/main/java/com/auction/client/controllers/GalleryController.java
+++ b/src/main/java/com/auction/client/controllers/GalleryController.java
@@ -4,8 +4,12 @@
import com.auction.shared.models.AuctionItem;
import javafx.application.Platform;
import javafx.fxml.FXML;
+import javafx.scene.control.Alert;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
+import javafx.scene.control.ComboBox;
+import javafx.scene.control.ChoiceBox;
+import javafx.scene.control.TextField;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.FlowPane;
@@ -18,60 +22,232 @@
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
import com.auction.client.service.ThumbnailExecutor;
public class GalleryController {
@FXML private FlowPane auctionFlow;
- @FXML private javafx.scene.control.TextField searchField;
+ @FXML private TextField searchField;
+ @FXML private ComboBox categoryCombo;
+ @FXML private ChoiceBox statusChoice;
+ @FXML private ChoiceBox sortChoice;
@FXML private Label auctionCountLabel;
private final Map thumbnailCache = new ConcurrentHashMap<>();
private static final Image PLACEHOLDER_IMAGE = loadPlaceholderImage();
private java.util.List allAuctions = java.util.List.of();
+ // Polling executor for live refresh (docs require 2s polling)
+ private ScheduledExecutorService pollExecutor;
+ private ScheduledFuture> pollTask;
+ private static final long POLL_INTERVAL_SECONDS = 2L;
@FXML
public void initialize() {
try {
- var context = ClientContext.getInstance();
- var service = context.getRmiProvider().getService();
- List items = service.getActiveAuctions();
- allAuctions = (items == null) ? java.util.List.of() : items;
- Platform.runLater(() -> renderAuctions(allAuctions));
- } catch (Exception e) {
- e.printStackTrace();
+ if (statusChoice != null && statusChoice.getItems().isEmpty()) {
+ statusChoice.getItems().addAll("Active & Upcoming", "Active", "Scheduled", "All", "Archived");
+ statusChoice.setValue("Active & Upcoming");
+ statusChoice.getSelectionModel().selectedItemProperty().addListener((a, b, c) -> fetchAndRenderAuctionsAsync());
+ }
+ if (sortChoice != null && sortChoice.getItems().isEmpty()) {
+ sortChoice.getItems().addAll("Newest", "Price: Low → High", "Price: High → Low");
+ sortChoice.setValue("Newest");
+ sortChoice.getSelectionModel().selectedItemProperty().addListener((a, b, c) -> fetchAndRenderAuctionsAsync());
+ }
+ if (categoryCombo != null) {
+ categoryCombo.getItems().setAll("All");
+ categoryCombo.setValue("All");
+ categoryCombo.setOnAction(evt -> fetchAndRenderAuctionsAsync());
+ }
+ renderLoadingState();
+ loadCategoriesThenQueryAsync();
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ Platform.runLater(() -> {
+ if (auctionCountLabel != null) {
+ auctionCountLabel.setText("Failed to initialize gallery");
+ }
+ });
+ } finally {
+ // start the periodic polling after attempting initial load
+ startPolling();
}
}
+ @FXML
+ private void handleRefresh() {
+ renderLoadingState();
+ loadCategoriesThenQueryAsync();
+ }
+
@FXML
private void handleSearch() {
- String q = searchField == null ? "" : searchField.getText();
- if (q == null || q.isBlank()) {
- renderAuctions(allAuctions);
- return;
+ renderLoadingState();
+ fetchAndRenderAuctionsAsync();
+ }
+
+ private void loadCategoriesThenQueryAsync() {
+ CompletableFuture.supplyAsync(() -> {
+ try {
+ var service = ClientContext.getInstance().getRmiProvider().getService();
+ return service.getAllAuctions();
+ } catch (Exception e) {
+ e.printStackTrace();
+ return java.util.List.of();
+ }
+ }, ThumbnailExecutor.getExecutor()).thenAccept(items -> {
+ allAuctions = (items == null) ? java.util.List.of() : items;
+ var cats = allAuctions.stream()
+ .map(AuctionItem::getCategory)
+ .filter(c -> c != null && !c.isBlank())
+ .map(String::trim)
+ .collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new));
+ Platform.runLater(() -> {
+ if (categoryCombo != null) {
+ String previous = categoryCombo.getValue();
+ categoryCombo.getItems().clear();
+ categoryCombo.getItems().add("All");
+ categoryCombo.getItems().addAll(cats);
+ if (previous != null && categoryCombo.getItems().contains(previous)) {
+ categoryCombo.setValue(previous);
+ } else {
+ categoryCombo.setValue("All");
+ }
+ }
+ fetchAndRenderAuctionsAsync();
+ });
+ });
+ }
+
+ private void fetchAndRenderAuctionsAsync() {
+ String query = searchField == null ? null : searchField.getText();
+ String selectedCategory = categoryCombo == null ? null : categoryCombo.getValue();
+ if ("All".equals(selectedCategory)) selectedCategory = null;
+ String sortBy = mapSortToServer(sortChoice == null ? null : sortChoice.getValue());
+
+ final String q = query;
+ final String c = selectedCategory;
+ final String s = sortBy;
+
+ // Default: show ACTIVE + SCHEDULED auctions. The server's default searchAllAuctions
+ // may only return active items, so request all and filter client-side by status.
+ CompletableFuture.supplyAsync(() -> {
+ try {
+ var service = ClientContext.getInstance().getRmiProvider().getService();
+ return service.getAllAuctions();
+ } catch (Exception e) {
+ e.printStackTrace();
+ return allAuctions == null ? java.util.List.of() : allAuctions;
+ }
+ }, ThumbnailExecutor.getExecutor()).thenAccept(items -> {
+ java.util.List base = (items == null) ? java.util.List.of() : items;
+ String statusFilter = statusChoice == null ? "Active & Upcoming" : statusChoice.getValue();
+ java.util.List filteredByStatus;
+ if ("Active & Upcoming".equalsIgnoreCase(statusFilter)) {
+ filteredByStatus = base.stream()
+ .filter(a -> a != null && (
+ com.auction.shared.Constants.STATUS_ACTIVE.equalsIgnoreCase(a.getStatus()) ||
+ com.auction.shared.Constants.STATUS_SCHEDULED.equalsIgnoreCase(a.getStatus())
+ ))
+ .toList();
+ } else if ("Active".equalsIgnoreCase(statusFilter)) {
+ filteredByStatus = base.stream()
+ .filter(a -> a != null && com.auction.shared.Constants.STATUS_ACTIVE.equalsIgnoreCase(a.getStatus()))
+ .toList();
+ } else if ("Scheduled".equalsIgnoreCase(statusFilter)) {
+ filteredByStatus = base.stream()
+ .filter(a -> a != null && com.auction.shared.Constants.STATUS_SCHEDULED.equalsIgnoreCase(a.getStatus()))
+ .toList();
+ } else if ("Archived".equalsIgnoreCase(statusFilter)) {
+ filteredByStatus = base.stream()
+ .filter(a -> a != null && (
+ com.auction.shared.Constants.STATUS_SOLD.equalsIgnoreCase(a.getStatus()) ||
+ com.auction.shared.Constants.STATUS_CANCELLED.equalsIgnoreCase(a.getStatus()) ||
+ com.auction.shared.Constants.STATUS_EXPIRED.equalsIgnoreCase(a.getStatus())
+ ))
+ .toList();
+ } else {
+ filteredByStatus = base;
+ }
+
+ java.util.List finalList = fallbackFilterAndSort(filteredByStatus, q, c, s);
+ Platform.runLater(() -> renderAuctions(finalList == null ? java.util.List.of() : finalList));
+ });
+ }
+
+ private java.util.List fallbackFilterAndSort(java.util.List base, String query, String category, String sortBy) {
+ java.util.stream.Stream stream = (base == null ? java.util.List.of() : base).stream();
+
+ String q = query == null ? "" : query.trim().toLowerCase();
+ if (!q.isBlank()) {
+ stream = stream.filter(a -> containsIgnoreCase(a.getTitle(), q)
+ || containsIgnoreCase(a.getDescription(), q)
+ || containsIgnoreCase(a.getCategory(), q));
}
- String needle = q.trim().toLowerCase();
- java.util.List filtered = allAuctions.stream()
- .filter(a -> containsIgnoreCase(a.getTitle(), needle)
- || containsIgnoreCase(a.getDescription(), needle)
- || containsIgnoreCase(a.getCategory(), needle))
- .toList();
- renderAuctions(filtered);
+ String c = category == null ? "" : category.trim();
+ if (!c.isBlank()) {
+ stream = stream.filter(a -> c.equalsIgnoreCase(a.getCategory()));
+ }
+
+ java.util.List filtered = stream.toList();
+ if ("price_asc".equals(sortBy)) {
+ return filtered.stream()
+ .sorted(java.util.Comparator.comparingLong(AuctionItem::getCurrentBidCents))
+ .toList();
+ }
+ if ("price_desc".equals(sortBy)) {
+ return filtered.stream()
+ .sorted(java.util.Comparator.comparingLong(AuctionItem::getCurrentBidCents).reversed())
+ .toList();
+ }
+ return filtered.stream()
+ .sorted((a, b) -> b.getEndTime().compareTo(a.getEndTime()))
+ .toList();
}
- private boolean containsIgnoreCase(String value, String needleLower) {
+ private static boolean containsIgnoreCase(String value, String needleLower) {
return value != null && value.toLowerCase().contains(needleLower);
}
+ private String mapSortToServer(String sortLabel) {
+ if ("Price: Low → High".equals(sortLabel)) return "price_asc";
+ if ("Price: High → Low".equals(sortLabel)) return "price_desc";
+ return "newest";
+ }
+
+ private void renderLoadingState() {
+ Platform.runLater(() -> {
+ if (auctionFlow != null) {
+ auctionFlow.getChildren().clear();
+ VBox loading = new VBox();
+ loading.getStyleClass().add("metric-card");
+ Label t = new Label("Loading auctions...");
+ t.getStyleClass().add("metric-label");
+ loading.getChildren().add(t);
+ auctionFlow.getChildren().add(loading);
+ }
+ if (auctionCountLabel != null) {
+ auctionCountLabel.setText("Loading...");
+ }
+ });
+ }
+
private void renderAuctions(java.util.List items) {
+ if (auctionFlow == null) {
+ return;
+ }
auctionFlow.getChildren().clear();
if (items == null || items.isEmpty()) {
VBox empty = new VBox();
empty.getStyleClass().add("metric-card");
Label t = new Label("No auctions found");
t.getStyleClass().add("metric-label");
- Label s = new Label("Try a different search term or connect to a live server.");
+ Label s = new Label("Try a different category or connect to a live server.");
s.getStyleClass().add("section-copy");
s.setWrapText(true);
empty.getChildren().addAll(t, s);
@@ -99,66 +275,117 @@ private VBox createCard(AuctionItem item) {
Label price = new Label(String.format("%s", com.auction.shared.Constants.formatCents(item.getCurrentBidCents())));
price.getStyleClass().add("section-copy");
+
+ Label status = new Label(item.getStatus() == null ? "ACTIVE" : item.getStatus());
+ status.getStyleClass().add("status-chip");
+ // apply semantic styling for common statuses
+ try {
+ String st = item.getStatus() == null ? "ACTIVE" : item.getStatus();
+ if (com.auction.shared.Constants.STATUS_ACTIVE.equalsIgnoreCase(st)) {
+ status.getStyleClass().add("status-chip-success");
+ } else if (com.auction.shared.Constants.STATUS_SCHEDULED.equalsIgnoreCase(st)) {
+ status.getStyleClass().add("status-chip-warning");
+ } else if (com.auction.shared.Constants.STATUS_SOLD.equalsIgnoreCase(st)) {
+ status.getStyleClass().add("status-chip-accent");
+ } else if (com.auction.shared.Constants.STATUS_CANCELLED.equalsIgnoreCase(st) || com.auction.shared.Constants.STATUS_EXPIRED.equalsIgnoreCase(st)) {
+ status.getStyleClass().add("status-chip-warning");
+ }
+ } catch (Exception ignored) {}
ImageView thumbView = new ImageView();
thumbView.setFitWidth(220);
thumbView.setFitHeight(140);
thumbView.setPreserveRatio(true);
- thumbView.getStyleClass().add("image-placeholder");
+ thumbView.setVisible(false);
+ thumbView.setManaged(false);
loadThumbnailAsync(item.getId(), 0, thumbView);
Button view = new Button("View");
view.getStyleClass().addAll("primary-button");
view.setOnAction(evt -> {
- try {
- ClientContext context = ClientContext.getInstance();
- context.setPreviousViewName("gallery.fxml");
- Object ctrl = context.getViewLoader().loadView("auction_detail.fxml");
- if (ctrl instanceof AuctionDetailController) {
- ((AuctionDetailController) ctrl).loadAuction(item.getId());
- }
- } catch (IOException ex) {
- ex.printStackTrace();
- }
+ openAuctionDetail(item, 0);
});
- // small thumbnail rail (up to 3 thumbnails)
+ // small thumbnail rail (up to 3 thumbnails) - include primary image as the first slot
javafx.scene.layout.HBox rail = new javafx.scene.layout.HBox(6);
- for (int i = 0; i < 3; i++) {
+ int[] railSlots = new int[] {0, 1, 2};
+ for (int slot : railSlots) {
ImageView small = new ImageView();
small.setFitWidth(64);
small.setFitHeight(48);
small.setPreserveRatio(true);
- small.getStyleClass().add("image-thumb");
- final int idx = i;
- small.setOnMouseClicked(e -> {
- try {
- ClientContext context = ClientContext.getInstance();
- context.setPreviousViewName("gallery.fxml");
- Object ctrl = context.getViewLoader().loadView("auction_detail.fxml");
- if (ctrl instanceof AuctionDetailController) {
- ((AuctionDetailController) ctrl).loadAuction(item.getId());
- ((AuctionDetailController) ctrl).showHeroImageIndex(idx);
- }
- } catch (IOException ex) {
- ex.printStackTrace();
- }
- });
- // prefetch hero on hover for snappier detail view
- small.setOnMouseEntered(e -> loadThumbnailToCache(item.getId(), 0));
- loadThumbnailAsync(item.getId(), i, small);
+ small.setVisible(false);
+ small.setManaged(false);
+ if (slot >= 0) {
+ final int idx = slot;
+ small.setOnMouseClicked(e -> openAuctionDetail(item, idx));
+ small.setOnMouseEntered(e -> loadThumbnailToCache(item.getId(), idx));
+ loadThumbnailAsync(item.getId(), idx, small);
+ } else {
+ small.setVisible(false);
+ small.setManaged(false);
+ }
rail.getChildren().add(small);
}
- card.getChildren().addAll(thumbView, rail, title, price, view);
+ card.getChildren().addAll(thumbView, rail, title, status, price, view);
return card;
}
+ private void openAuctionDetail(AuctionItem item, int heroIndex) {
+ try {
+ ClientContext context = ClientContext.getInstance();
+ // Diagnostic log: record gallery->detail navigation attempts
+ System.out.println("[RTDAS][Gallery] openAuctionDetail called; auctionId=" + item.getId() + ", heroIndex=" + heroIndex + ", currentAuctionId=" + context.getCurrentAuctionId() + ", previousViewName=" + context.getPreviousViewName());
+ context.setCurrentAuctionId(item.getId());
+ Object ctrl = context.getViewLoader().loadView("auction_detail.fxml");
+ if (ctrl instanceof AuctionDetailController) {
+ AuctionDetailController detailController = (AuctionDetailController) ctrl;
+ detailController.setReturnViewName("gallery.fxml");
+ if (heroIndex >= 0) {
+ Platform.runLater(() -> detailController.showHeroImageIndex(heroIndex));
+ }
+ } else {
+ showNavigationError("Auction detail controller was not available.");
+ }
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ showNavigationError("Unable to open auction detail: " + ex.getMessage());
+ }
+ }
+
+ private void showNavigationError(String message) {
+ Platform.runLater(() -> {
+ if (auctionCountLabel != null) {
+ auctionCountLabel.setText(message);
+ }
+ Alert alert = new Alert(Alert.AlertType.ERROR);
+ alert.setTitle("Navigation Error");
+ alert.setHeaderText("Could not open auction detail");
+ alert.setContentText(message);
+ alert.showAndWait();
+ });
+ }
+
private void loadThumbnailAsync(int auctionId, int index, ImageView target) {
+ if (index < 0) {
+ Platform.runLater(() -> {
+ target.setImage(null);
+ target.setVisible(false);
+ target.setManaged(false);
+ target.setStyle(null);
+ });
+ return;
+ }
String key = auctionId + ":" + index;
Image cached = thumbnailCache.get(key);
if (cached != null) {
- target.setImage(cached);
+ Platform.runLater(() -> {
+ target.setImage(cached);
+ target.setVisible(true);
+ target.setManaged(true);
+ target.setStyle(null);
+ });
return;
}
@@ -177,11 +404,15 @@ private void loadThumbnailAsync(int auctionId, int index, ImageView target) {
thumbnailCache.put(key, image);
Platform.runLater(() -> {
target.setImage(image);
+ target.setVisible(true);
+ target.setManaged(true);
target.setStyle(null);
});
} else {
Platform.runLater(() -> {
target.setImage(PLACEHOLDER_IMAGE);
+ target.setVisible(true);
+ target.setManaged(true);
target.setStyle(null);
});
}
@@ -208,7 +439,7 @@ private void loadThumbnailToCache(int auctionId, int index) {
private static Image loadPlaceholderImage() {
InputStream stream = GalleryController.class.getResourceAsStream("/images/placeholder.png");
if (stream == null) {
- throw new IllegalStateException("Missing resource: /images/placeholder.png");
+ return null;
}
return new Image(stream);
}
@@ -216,14 +447,80 @@ private static Image loadPlaceholderImage() {
@FXML
private void handleBackToDashboard() {
try {
+ // stop polling before leaving the view
+ stopPolling();
+
ClientContext context = ClientContext.getInstance();
- String targetView = context.getPreviousViewName();
- if (targetView == null || targetView.isBlank()) {
- targetView = "user_dashboard.fxml";
- }
+ // If previousViewName is missing or points at the gallery itself,
+ // prefer returning to the user dashboard so the button doesn't just reload the gallery.
+ String prev = context.getPreviousViewName();
+ System.out.println("[RTDAS][Gallery] handleBackToDashboard: previousViewName=" + prev);
+ String targetView = (prev == null || prev.isBlank() || "gallery.fxml".equalsIgnoreCase(prev))
+ ? "user_dashboard.fxml"
+ : prev;
context.getViewLoader().loadView(targetView);
} catch (IOException e) {
e.printStackTrace();
}
}
+
+ @FXML
+ public void handleLogout(javafx.event.ActionEvent event) {
+ try {
+ // stop background polling first
+ stopPolling();
+
+ ClientContext context = ClientContext.getInstance();
+ try {
+ var svc = context.getRmiProvider().getService();
+ svc.logout(context.getSessionToken());
+ } catch (Exception e) {
+ // best-effort logout; log but continue to clear session locally
+ e.printStackTrace();
+ }
+ context.clearSession();
+ context.getViewLoader().loadView("login.fxml");
+ } catch (IOException e) {
+ e.printStackTrace();
+ if (auctionCountLabel != null) auctionCountLabel.setText("Logout failed: " + e.getMessage());
+ }
+ }
+
+ private void startPolling() {
+ try {
+ if (pollExecutor == null || pollExecutor.isShutdown()) {
+ pollExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "gallery-poller");
+ t.setDaemon(true);
+ return t;
+ });
+ }
+ // schedule with fixed rate; first run after interval to allow initial load
+ pollTask = pollExecutor.scheduleAtFixedRate(() -> {
+ try {
+ fetchAndRenderAuctionsAsync();
+ } catch (Throwable t) {
+ t.printStackTrace();
+ }
+ }, POLL_INTERVAL_SECONDS, POLL_INTERVAL_SECONDS, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ private void stopPolling() {
+ try {
+ if (pollTask != null && !pollTask.isCancelled()) {
+ pollTask.cancel(false);
+ }
+ if (pollExecutor != null && !pollExecutor.isShutdown()) {
+ pollExecutor.shutdownNow();
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ } finally {
+ pollTask = null;
+ pollExecutor = null;
+ }
+ }
}
diff --git a/src/main/java/com/auction/client/controllers/UserDashboardController.java b/src/main/java/com/auction/client/controllers/UserDashboardController.java
index ab32a3d..d8871d8 100644
--- a/src/main/java/com/auction/client/controllers/UserDashboardController.java
+++ b/src/main/java/com/auction/client/controllers/UserDashboardController.java
@@ -1,8 +1,17 @@
package com.auction.client.controllers;
import javafx.fxml.FXML;
+import javafx.scene.control.Alert;
+import javafx.scene.control.ButtonBar;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.Button;
import javafx.scene.control.Label;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Iterator;
+
public class UserDashboardController {
@FXML
@@ -20,6 +29,9 @@ public class UserDashboardController {
com.auction.shared.models.Bid
> myBidsTable;
+ @FXML
+ private javafx.scene.control.TableColumn myBidsAmountColumn;
+
@FXML
private javafx.scene.control.TableView<
com.auction.shared.models.AuctionItem
@@ -39,6 +51,22 @@ public class UserDashboardController {
@FXML
private javafx.scene.control.TextField endTimeField;
+ @FXML
+ private javafx.scene.control.DatePicker startDatePicker;
+ @FXML
+ private javafx.scene.control.TextField startTimeField;
+ @FXML
+ private javafx.scene.control.DatePicker endDatePicker;
+ @FXML
+ private javafx.scene.control.ChoiceBox startModeChoice;
+
+ @FXML
+ private javafx.scene.control.TextField minIncrementField;
+ @FXML
+ private javafx.scene.control.TextField capEndMinutesField;
+
+ @FXML
+ private Button createAuctionButton;
@FXML
private javafx.scene.control.Label imagesLabel;
@@ -58,54 +86,373 @@ public class UserDashboardController {
@FXML
private Label winsCountLabel;
- private byte[] img1Bytes, img2Bytes, img3Bytes;
+ @FXML
+ private Label totalSalesLabel;
+
+ @FXML
+ private javafx.scene.control.ChoiceBox listingsStatusChoice;
+
+ @FXML
+ private javafx.scene.control.TableColumn endedAtColumn;
+
+ @FXML
+ private javafx.scene.control.TableColumn marketCurrentBidColumn;
+
+ @FXML
+ private javafx.scene.control.TableColumn myListingsCurrentBidColumn;
+
+ @FXML
+ private javafx.scene.control.TableColumn wonAuctionsCurrentBidColumn;
+
+ private static final String LISTING_FILTER_ALL = "All";
+ private static final String LISTING_FILTER_SCHEDULED = "Scheduled";
+ private static final String LISTING_FILTER_ACTIVE = "Active";
+ private static final String LISTING_FILTER_SOLD = "Sold";
+ private static final String LISTING_FILTER_EXPIRED = "Expired";
+ private static final String LISTING_FILTER_CANCELLED = "Cancelled";
+
+ private java.util.List allMyListings = java.util.List.of();
+
+ private Integer editingAuctionId = null;
+
+ private byte[] img1Bytes;
+ private byte[] img2Bytes;
+ private byte[] img3Bytes;
@FXML
public void initialize() {
+ configureStartModeChoice();
+ configureEndedAtColumn();
+ configureCurrencyColumn(marketCurrentBidColumn);
+ configureCurrencyColumn(myListingsCurrentBidColumn);
+ configureCurrencyColumn(wonAuctionsCurrentBidColumn);
+ configureListingsFilter();
+ try {
+ if (totalSalesLabel != null && (totalSalesLabel.getText() == null || totalSalesLabel.getText().isBlank())) {
+ totalSalesLabel.setText(com.auction.shared.Constants.formatCents(0L));
+ }
+ } catch (Exception ignored) {}
+ // Default seller min increment percent to 5 for convenience
+ try { if (minIncrementField != null && (minIncrementField.getText() == null || minIncrementField.getText().isBlank())) minIncrementField.setText("5"); } catch (Exception ignored) {}
+ try { if (capEndMinutesField != null && (capEndMinutesField.getText() == null || capEndMinutesField.getText().isBlank())) capEndMinutesField.setText(String.valueOf(com.auction.shared.Constants.SNIPE_CAP_DEFAULT_MINUTES)); } catch (Exception ignored) {}
+ updateEditMode(false, null);
refreshDashboard();
}
- private void refreshDashboard() {
+ private void configureCurrencyColumn(javafx.scene.control.TableColumn column) {
try {
- com.auction.client.core.ClientContext context =
- com.auction.client.core.ClientContext.getInstance();
- com.auction.shared.interfaces.IAuctionService service = context
- .getRmiProvider()
- .getService();
- java.util.List activeAuctions =
- service.getActiveAuctions();
- java.util.List mine =
- service.getActiveAuctionsBySeller(
- context.getUsername(),
+ if (column == null) return;
+ column.setCellFactory(col -> new javafx.scene.control.TableCell() {
+ @Override
+ protected void updateItem(Number item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null) {
+ setText(null);
+ return;
+ }
+ setText(com.auction.shared.Constants.formatCents(item.longValue()));
+ }
+ });
+ } catch (Exception ignored) {}
+ }
+
+ private void configureStartModeChoice() {
+ if (startModeChoice == null) return;
+ if (startModeChoice.getItems().isEmpty()) {
+ startModeChoice.getItems().addAll("Automatic", "Manual");
+ }
+ if (startModeChoice.getValue() == null) startModeChoice.setValue("Automatic");
+ }
+
+ private void configureEndedAtColumn() {
+ try {
+ if (endedAtColumn == null) return;
+ endedAtColumn.setCellFactory(col -> new javafx.scene.control.TableCell() {
+ private final java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
+ @Override
+ protected void updateItem(String item, boolean empty) {
+ super.updateItem(item, empty);
+ if (empty || item == null || item.isBlank()) {
+ setText(null);
+ return;
+ }
+ try {
+ java.time.Instant inst = java.time.Instant.parse(item);
+ java.time.ZonedDateTime zdt = java.time.ZonedDateTime.ofInstant(inst, java.time.ZoneId.systemDefault());
+ setText(zdt.format(fmt));
+ } catch (Exception e) {
+ setText(item);
+ }
+ }
+ });
+ } catch (Exception ignored) {}
+ }
+
+ private void configureListingsFilter() {
+ if (listingsStatusChoice == null) return;
+
+ if (listingsStatusChoice.getItems().isEmpty()) {
+ listingsStatusChoice
+ .getItems()
+ .addAll(
+ LISTING_FILTER_ALL,
+ LISTING_FILTER_SCHEDULED,
+ LISTING_FILTER_ACTIVE,
+ LISTING_FILTER_SOLD,
+ LISTING_FILTER_EXPIRED,
+ LISTING_FILTER_CANCELLED
+ );
+ }
+
+ if (listingsStatusChoice.getValue() == null) {
+ listingsStatusChoice.setValue(LISTING_FILTER_ALL);
+ }
+
+ listingsStatusChoice
+ .getSelectionModel()
+ .selectedItemProperty()
+ .addListener((obs, oldValue, newValue) -> applyListingsFilter());
+ }
+
+ @FXML
+ private void handlePrepareEditAuction() {
+ com.auction.shared.models.AuctionItem selected = myListingsTable == null ? null : myListingsTable.getSelectionModel().getSelectedItem();
+ if (selected == null) {
+ if (statusLabel != null) statusLabel.setText("Select one of your scheduled listings to edit.");
+ return;
+ }
+
+ if (!com.auction.shared.Constants.STATUS_SCHEDULED.equalsIgnoreCase(selected.getStatus())) {
+ if (statusLabel != null) statusLabel.setText("Only scheduled listings can be edited before they start.");
+ return;
+ }
+
+ if (selected.getSellerUsername() != null && !selected.getSellerUsername().equalsIgnoreCase(com.auction.client.core.ClientContext.getInstance().getUsername())) {
+ if (statusLabel != null) statusLabel.setText("You can only edit your own listings.");
+ return;
+ }
+
+ populateEditForm(selected);
+ updateEditMode(true, selected.getId());
+ if (statusLabel != null) statusLabel.setText("Editing auction #" + selected.getId());
+ }
+
+ @FXML
+ private void handleCancelEditMode() {
+ clearEditMode();
+ }
+
+ private void updateEditMode(boolean editing, Integer auctionId) {
+ editingAuctionId = editing ? auctionId : null;
+ if (createAuctionButton != null) {
+ createAuctionButton.setText(editing ? "Save Changes" : "Create Auction");
+ }
+ }
+
+ private void clearEditMode() {
+ updateEditMode(false, null);
+ clearAuctionForm();
+ if (statusLabel != null) statusLabel.setText("Edit cancelled.");
+ }
+
+ private void clearAuctionForm() {
+ if (titleField != null) titleField.clear();
+ if (descArea != null) descArea.clear();
+ if (categoryField != null) categoryField.clear();
+ if (priceField != null) priceField.clear();
+ if (startDatePicker != null) startDatePicker.setValue(null);
+ if (startTimeField != null) startTimeField.clear();
+ if (endDatePicker != null) endDatePicker.setValue(null);
+ if (endTimeField != null) endTimeField.clear();
+ if (startModeChoice != null) startModeChoice.setValue("Automatic");
+ if (minIncrementField != null) minIncrementField.setText("5");
+ if (capEndMinutesField != null) capEndMinutesField.setText(String.valueOf(com.auction.shared.Constants.SNIPE_CAP_DEFAULT_MINUTES));
+ img1Bytes = img2Bytes = img3Bytes = null;
+ if (imagesLabel != null) imagesLabel.setText("No images selected");
+ }
+
+ private void populateEditForm(com.auction.shared.models.AuctionItem item) {
+ if (item == null) return;
+ if (titleField != null) titleField.setText(item.getTitle());
+ if (descArea != null) descArea.setText(item.getDescription());
+ if (categoryField != null) categoryField.setText(item.getCategory());
+ if (priceField != null) priceField.setText(String.format(java.util.Locale.ROOT, "%.2f", item.getStartingPriceCents() / 100.0));
+
+ try {
+ java.time.ZoneId zoneId = java.time.ZoneId.systemDefault();
+ if (item.getStartTime() != null && !item.getStartTime().isBlank()) {
+ java.time.LocalDateTime startDateTime = java.time.LocalDateTime.ofInstant(java.time.Instant.parse(item.getStartTime()), zoneId);
+ if (startDatePicker != null) startDatePicker.setValue(startDateTime.toLocalDate());
+ if (startTimeField != null) startTimeField.setText(startDateTime.toLocalTime().withSecond(0).withNano(0).toString());
+ }
+ if (item.getEndTime() != null && !item.getEndTime().isBlank()) {
+ java.time.LocalDateTime endDateTime = java.time.LocalDateTime.ofInstant(java.time.Instant.parse(item.getEndTime()), zoneId);
+ if (endDatePicker != null) endDatePicker.setValue(endDateTime.toLocalDate());
+ if (endTimeField != null) endTimeField.setText(endDateTime.toLocalTime().withSecond(0).withNano(0).toString());
+ }
+ if (item.getCapEndTime() != null && !item.getCapEndTime().isBlank() && item.getEndTime() != null && !item.getEndTime().isBlank()) {
+ java.time.Duration capOffset = java.time.Duration.between(java.time.Instant.parse(item.getEndTime()), java.time.Instant.parse(item.getCapEndTime()));
+ long capMinutes = Math.max(0L, capOffset.toMinutes());
+ if (capEndMinutesField != null) capEndMinutesField.setText(String.valueOf(capMinutes));
+ }
+ } catch (Exception ignored) {}
+
+ if (startModeChoice != null) {
+ startModeChoice.setValue(com.auction.shared.Constants.START_MODE_MANUAL.equalsIgnoreCase(item.getStartMode()) ? "Manual" : "Automatic");
+ }
+ if (minIncrementField != null) {
+ long pct = Math.round(item.getMinIncrementPercent() * 100.0);
+ minIncrementField.setText(String.valueOf(pct));
+ }
+ if (imagesLabel != null) {
+ imagesLabel.setText("Current images retained unless changed.");
+ }
+ img1Bytes = img2Bytes = img3Bytes = null;
+ }
+
+ private com.auction.shared.models.AuctionItem buildAuctionFromForm() {
+ long cents = (long) (Double.parseDouble(priceField.getText()) * 100);
+ java.time.Instant now = java.time.Instant.now();
+ java.time.Instant startInstant = now;
+ if (startDatePicker != null && startDatePicker.getValue() != null && startTimeField != null && !startTimeField.getText().isBlank()) {
+ java.time.LocalDate d = startDatePicker.getValue();
+ java.time.LocalTime t = java.time.LocalTime.parse(startTimeField.getText());
+ startInstant = java.time.ZonedDateTime.of(d, t, java.time.ZoneId.systemDefault()).toInstant();
+ }
+
+ java.time.Instant endInstant = startInstant.plus(java.time.Duration.ofHours(1));
+ if (endDatePicker != null && endDatePicker.getValue() != null && endTimeField != null && !endTimeField.getText().isBlank()) {
+ java.time.LocalDate ed = endDatePicker.getValue();
+ java.time.LocalTime et = java.time.LocalTime.parse(endTimeField.getText());
+ endInstant = java.time.ZonedDateTime.of(ed, et, java.time.ZoneId.systemDefault()).toInstant();
+ }
+
+ com.auction.shared.models.AuctionItem item = new com.auction.shared.models.AuctionItem();
+ item.setTitle(titleField.getText());
+ item.setDescription(descArea.getText());
+ item.setCategory(categoryField.getText());
+ item.setStartingPriceCents(cents);
+ item.setCurrentBidCents(cents);
+ item.setSellerUsername(com.auction.client.core.ClientContext.getInstance().getUsername());
+ item.setStartTime(startInstant.toString());
+ item.setEndTime(endInstant.toString());
+
+ if (capEndMinutesField != null && capEndMinutesField.getText() != null && !capEndMinutesField.getText().isBlank()) {
+ int minutes = Integer.parseInt(capEndMinutesField.getText().trim());
+ if (minutes < 0 || minutes > 24 * 60) {
+ throw new IllegalArgumentException("Snipe cap minutes must be between 0 and 1440");
+ }
+ item.setCapEndTime(endInstant.plus(java.time.Duration.ofMinutes(minutes)).toString());
+ } else {
+ item.setCapEndTime(null);
+ }
+
+ String mode = startModeChoice == null ? "Automatic" : startModeChoice.getValue();
+ if ("Manual".equalsIgnoreCase(mode)) {
+ item.setStartMode(com.auction.shared.Constants.START_MODE_MANUAL);
+ item.setStatus(com.auction.shared.Constants.STATUS_SCHEDULED);
+ } else {
+ item.setStartMode(com.auction.shared.Constants.START_MODE_AUTO);
+ item.setStatus(startInstant.isAfter(now) ? com.auction.shared.Constants.STATUS_SCHEDULED : com.auction.shared.Constants.STATUS_ACTIVE);
+ }
+
+ if (minIncrementField != null && minIncrementField.getText() != null && !minIncrementField.getText().isBlank()) {
+ double pct = Double.parseDouble(minIncrementField.getText().trim());
+ if (pct < 0 || pct > 100) {
+ throw new IllegalArgumentException("Min increment percent must be between 0 and 100");
+ }
+ item.setMinIncrementPercent(pct / 100.0);
+ }
+
+ return item;
+ }
+
+ private void refreshDashboard() {
+ javafx.concurrent.Task task = new javafx.concurrent.Task<>() {
+ @Override
+ protected DashboardSnapshot call() throws Exception {
+ com.auction.client.core.ClientContext context =
+ com.auction.client.core.ClientContext.getInstance();
+ com.auction.shared.interfaces.IAuctionService service = context
+ .getRmiProvider()
+ .getService();
+ java.util.List activeAuctions =
+ service.getActiveAuctions();
+ java.util.List mine =
+ service.getActiveAuctionsBySeller(
+ context.getUsername(),
+ context.getSessionToken()
+ );
+ java.util.List bids = service.getMyBids(
context.getSessionToken()
);
- java.util.List bids = service.getMyBids(
- context.getSessionToken()
- );
- java.util.List won =
- service.getMyWonAuctions(context.getSessionToken());
+ java.util.List won =
+ service.getMyWonAuctions(context.getSessionToken());
+ return new DashboardSnapshot(activeAuctions, mine, bids, won);
+ }
+ };
- marketTable.getItems().setAll(activeAuctions);
- myListingsTable.getItems().setAll(mine);
- myBidsTable.getItems().setAll(bids);
- wonAuctionsTable.getItems().setAll(won);
+ task.setOnSucceeded(evt -> {
+ DashboardSnapshot snapshot = task.getValue();
+ if (snapshot == null) {
+ if (statusLabel != null) statusLabel.setText("Dashboard refresh returned no data.");
+ return;
+ }
- if (marketCountLabel != null) marketCountLabel.setText(
- String.valueOf(activeAuctions.size())
- );
- if (listingsCountLabel != null) listingsCountLabel.setText(
- String.valueOf(mine.size())
- );
- if (bidsCountLabel != null) bidsCountLabel.setText(
- String.valueOf(bids.size())
- );
- if (winsCountLabel != null) winsCountLabel.setText(
- String.valueOf(won.size())
- );
- statusLabel.setText("Dashboard refreshed successfully.");
- } catch (Exception e) {
- statusLabel.setText("Failed to load dashboard: " + e.getMessage());
+ marketTable.getItems().setAll(snapshot.activeAuctions);
+ allMyListings = snapshot.mine;
+ applyListingsFilter();
+ myBidsTable.getItems().setAll(snapshot.bids);
+ wonAuctionsTable.getItems().setAll(snapshot.won);
+
+ long totalSalesCents = 0L;
+ for (com.auction.shared.models.AuctionItem item : snapshot.mine) {
+ if (item != null && com.auction.shared.Constants.STATUS_SOLD.equals(item.getStatus())) {
+ totalSalesCents += Math.max(0L, item.getCurrentBidCents());
+ }
+ }
+
+ if (totalSalesLabel != null) totalSalesLabel.setText(com.auction.shared.Constants.formatCents(totalSalesCents));
+
+ if (marketCountLabel != null) marketCountLabel.setText(String.valueOf(snapshot.activeAuctions.size()));
+ if (listingsCountLabel != null) listingsCountLabel.setText(String.valueOf(snapshot.mine.size()));
+ if (bidsCountLabel != null) bidsCountLabel.setText(String.valueOf(snapshot.bids.size()));
+ if (winsCountLabel != null) winsCountLabel.setText(String.valueOf(snapshot.won.size()));
+ if (statusLabel != null) statusLabel.setText("Dashboard refreshed successfully.");
+ });
+
+ task.setOnFailed(evt -> {
+ Throwable error = task.getException();
+ if (statusLabel != null) {
+ statusLabel.setText("Failed to load dashboard: " + (error == null ? "unknown error" : error.getMessage()));
+ }
+ });
+
+ Thread thread = new Thread(task, "dashboard-refresh");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ private void applyListingsFilter() {
+ String selected = listingsStatusChoice == null
+ ? LISTING_FILTER_ALL
+ : listingsStatusChoice.getValue();
+
+ if (
+ selected == null ||
+ selected.isBlank() ||
+ LISTING_FILTER_ALL.equalsIgnoreCase(selected)
+ ) {
+ myListingsTable.getItems().setAll(allMyListings);
+ return;
}
+
+ java.util.List filtered =
+ allMyListings
+ .stream()
+ .filter(item -> item != null && selected.equalsIgnoreCase(item.getStatus()))
+ .toList();
+ myListingsTable.getItems().setAll(filtered);
}
@FXML
@@ -120,8 +467,11 @@ private void handleOpenGallery() {
com.auction.client.core.ClientContext.getInstance();
context.setPreviousViewName("user_dashboard.fxml");
context.getViewLoader().loadView("gallery.fxml");
- } catch (java.io.IOException e) {
- throw new RuntimeException(e);
+ } catch (Exception e) {
+ if (statusLabel != null) {
+ statusLabel.setText("Unable to open gallery: " + e.getMessage());
+ }
+ e.printStackTrace();
}
}
@@ -142,7 +492,10 @@ private void handleOpenAuctionDetail() {
com.auction.client.core.ClientContext context = com.auction.client.core.ClientContext.getInstance();
context.setCurrentAuctionId(selected.getId());
context.setPreviousViewName("user_dashboard.fxml");
- context.getViewLoader().loadView("auction_detail.fxml");
+ Object controller = context.getViewLoader().loadView("auction_detail.fxml");
+ if (controller instanceof com.auction.client.controllers.AuctionDetailController detailController) {
+ detailController.setReturnViewName("user_dashboard.fxml");
+ }
} catch (java.io.IOException e) {
throw new RuntimeException(e);
}
@@ -154,49 +507,52 @@ private void handleOpenAuctionDetail() {
@FXML
private void handleCreateAuction() {
try {
- long cents = (long) (Double.parseDouble(priceField.getText()) * 100);
- int minutes = Integer.parseInt(endTimeField.getText());
- java.time.Instant end = java.time.Instant.now().plus(
- java.time.Duration.ofMinutes(minutes)
- );
+ com.auction.shared.models.AuctionItem item = buildAuctionFromForm();
+ if (!java.time.Instant.parse(item.getEndTime()).isAfter(java.time.Instant.parse(item.getStartTime()))) {
+ statusLabel.setText("End time must be after start time");
+ return;
+ }
- com.auction.shared.models.AuctionItem item =
- new com.auction.shared.models.AuctionItem(
- 0,
- titleField.getText(),
- descArea.getText(),
- categoryField.getText(),
- cents,
- com.auction.client.core.ClientContext.getInstance().getUsername(),
- java.time.Instant.now().toString(),
- end.toString(),
- null
- );
+ if (item.getStartMode() == null || item.getStartMode().isBlank()) {
+ item.setStartMode(com.auction.shared.Constants.START_MODE_AUTO);
+ }
- com.auction.client.core.ClientContext context =
- com.auction.client.core.ClientContext.getInstance();
- int id = context
- .getRmiProvider()
- .getService()
- .createAuction(
- item,
- img1Bytes,
- img2Bytes,
- img3Bytes,
- context.getSessionToken()
- );
- statusLabel.setText("Created auction #" + id);
- refreshDashboard();
+ if (editingAuctionId != null) {
+ com.auction.client.core.ClientContext context = com.auction.client.core.ClientContext.getInstance();
+ context.getRmiProvider().getService().updateAuction(editingAuctionId, item, img1Bytes, img2Bytes, img3Bytes, context.getSessionToken());
+ statusLabel.setText("Updated auction #" + editingAuctionId);
+ } else {
+ com.auction.client.core.ClientContext context =
+ com.auction.client.core.ClientContext.getInstance();
+ int id = context
+ .getRmiProvider()
+ .getService()
+ .createAuction(
+ item,
+ img1Bytes,
+ img2Bytes,
+ img3Bytes,
+ context.getSessionToken()
+ );
+ statusLabel.setText("Created auction #" + id);
+ Alert alert = new Alert(Alert.AlertType.INFORMATION);
+ alert.setTitle("Auction Created");
+ alert.setHeaderText("Auction created successfully");
+ alert.setContentText("Your auction #" + id + " is ready.");
+ alert.showAndWait();
+ refreshDashboard();
+ selectCreatedAuction(id);
+
+ clearAuctionForm();
+ return;
+ }
- titleField.clear();
- descArea.clear();
- categoryField.clear();
- priceField.clear();
- endTimeField.clear();
- img1Bytes = img2Bytes = img3Bytes = null;
- imagesLabel.setText("No images selected");
+ refreshDashboard();
+ clearEditMode();
+ } catch (IllegalArgumentException e) {
+ statusLabel.setText(e.getMessage());
} catch (Exception e) {
- statusLabel.setText("Error creating: " + e.getMessage());
+ statusLabel.setText(editingAuctionId != null ? "Error saving: " + e.getMessage() : "Error creating: " + e.getMessage());
}
}
@@ -247,6 +603,33 @@ private void handleRelistAuction() {
}
}
+ @FXML
+ private void handleStartAuction() {
+ com.auction.shared.models.AuctionItem selected = null;
+ if (marketTable.getSelectionModel().getSelectedItem() != null) {
+ selected = marketTable.getSelectionModel().getSelectedItem();
+ } else if (myListingsTable.getSelectionModel().getSelectedItem() != null) {
+ selected = myListingsTable.getSelectionModel().getSelectedItem();
+ } else if (wonAuctionsTable.getSelectionModel().getSelectedItem() != null) {
+ selected = wonAuctionsTable.getSelectionModel().getSelectedItem();
+ }
+
+ if (selected != null) {
+ try {
+ com.auction.client.core.ClientContext context = com.auction.client.core.ClientContext.getInstance();
+ context.getRmiProvider().getService().startAuction(selected.getId(), context.getSessionToken());
+ refreshDashboard();
+ if (com.auction.shared.Constants.START_MODE_MANUAL.equalsIgnoreCase(selected.getStartMode())) {
+ showManualLaunchDialog(selected.getId());
+ }
+ } catch (Exception e) {
+ if (statusLabel != null) statusLabel.setText("Start failed: " + e.getMessage());
+ }
+ } else {
+ if (statusLabel != null) statusLabel.setText("Please select an auction first.");
+ }
+ }
+
@FXML
private void handleExportCSV() {
try {
@@ -256,48 +639,135 @@ private void handleExportCSV() {
.getRmiProvider()
.getService()
.exportAuctionsToCSV(context.getSessionToken());
- java.io.File file = new java.io.File("my_auctions_export.csv");
- java.nio.file.Files.write(file.toPath(), csv);
- statusLabel.setText("Exported to " + file.getAbsolutePath());
+
+ javafx.stage.FileChooser fileChooser = new javafx.stage.FileChooser();
+ fileChooser.setTitle("Save Auction CSV Export");
+ fileChooser.getExtensionFilters().add(
+ new javafx.stage.FileChooser.ExtensionFilter("CSV Files", "*.csv")
+ );
+ fileChooser.setInitialFileName("my_auctions_export.csv");
+
+ java.io.File exportsDir = new java.io.File("exports");
+ if (!exportsDir.exists() && !exportsDir.mkdirs()) {
+ throw new IOException("Unable to create exports directory");
+ }
+ fileChooser.setInitialDirectory(exportsDir);
+
+ java.io.File file = fileChooser.showSaveDialog(marketTable.getScene().getWindow());
+ if (file == null) {
+ if (statusLabel != null) statusLabel.setText("Export cancelled.");
+ return;
+ }
+
+ String fileName = file.getName().toLowerCase(java.util.Locale.ROOT).endsWith(".csv")
+ ? file.getName()
+ : file.getName() + ".csv";
+ java.io.File targetFile = new java.io.File(exportsDir, fileName);
+
+ java.nio.file.Files.write(targetFile.toPath(), csv);
+ if (statusLabel != null) statusLabel.setText("Exported to " + targetFile.getAbsolutePath());
} catch (Exception e) {
- statusLabel.setText("Export failed: " + e.getMessage());
+ if (statusLabel != null) statusLabel.setText("Export failed: " + e.getMessage());
}
}
@FXML
private void handlePickImg1() {
- img1Bytes = pickImage();
+ byte[] selected = pickImage("Image 1");
+ if (selected != null) img1Bytes = selected;
updateImagesLabel();
}
@FXML
private void handlePickImg2() {
- img2Bytes = pickImage();
+ byte[] selected = pickImage("Image 2");
+ if (selected != null) img2Bytes = selected;
updateImagesLabel();
}
@FXML
private void handlePickImg3() {
- img3Bytes = pickImage();
+ byte[] selected = pickImage("Image 3");
+ if (selected != null) img3Bytes = selected;
updateImagesLabel();
}
- private byte[] pickImage() {
+ private byte[] pickImage(String label) {
javafx.stage.FileChooser fc = new javafx.stage.FileChooser();
- fc
- .getExtensionFilters()
- .add(
- new javafx.stage.FileChooser.ExtensionFilter("Images", "*.jpg", "*.png")
- );
- java.io.File f = fc.showOpenDialog(marketTable.getScene().getWindow());
- if (f != null) {
+ fc.getExtensionFilters().add(
+ new javafx.stage.FileChooser.ExtensionFilter(
+ "Images (JPG, JPEG, PNG)",
+ "*.jpg",
+ "*.jpeg",
+ "*.png"
+ )
+ );
+ File file = fc.showOpenDialog(marketTable.getScene().getWindow());
+ if (file == null) {
+ return null;
+ }
+
+ try {
+ validateImageFile(file);
+ return Files.readAllBytes(file.toPath());
+ } catch (Exception e) {
+ showImageRejected(label, e.getMessage());
+ return null;
+ }
+ }
+
+ private void validateImageFile(File file) throws IOException {
+ long size = Files.size(file.toPath());
+ if (size > com.auction.shared.Constants.MAX_IMAGE_SIZE_BYTES) {
+ throw new IOException("Image must be 2 MB or smaller.");
+ }
+
+ try (javax.imageio.stream.ImageInputStream input = javax.imageio.ImageIO.createImageInputStream(file)) {
+ if (input == null) {
+ throw new IOException("Could not read image file.");
+ }
+
+ Iterator readers = javax.imageio.ImageIO.getImageReaders(input);
+ if (!readers.hasNext()) {
+ throw new IOException("Unsupported image format. Use JPG, JPEG, or PNG.");
+ }
+
+ javax.imageio.ImageReader reader = readers.next();
try {
- return java.nio.file.Files.readAllBytes(f.toPath());
- } catch (Exception e) {
- statusLabel.setText("Read failed");
+ reader.setInput(input, true, true);
+ String format = reader.getFormatName();
+ if (format == null || !com.auction.shared.Constants.SUPPORTED_IMAGE_FORMATS.contains(format.toLowerCase())) {
+ throw new IOException("Unsupported image format. Use JPG, JPEG, or PNG.");
+ }
+
+ int width = reader.getWidth(0);
+ int height = reader.getHeight(0);
+ if (width > com.auction.shared.Constants.MAX_IMAGE_WIDTH || height > com.auction.shared.Constants.MAX_IMAGE_HEIGHT) {
+ throw new IOException(
+ "Image dimensions must be at most " +
+ com.auction.shared.Constants.MAX_IMAGE_WIDTH +
+ "x" +
+ com.auction.shared.Constants.MAX_IMAGE_HEIGHT +
+ " pixels."
+ );
+ }
+ } finally {
+ reader.dispose();
}
}
- return null;
+ }
+
+ private void showImageRejected(String label, String reason) {
+ String message = label + " rejected: " + reason;
+ if (statusLabel != null) {
+ statusLabel.setText(message);
+ }
+
+ Alert alert = new Alert(Alert.AlertType.WARNING);
+ alert.setTitle("Invalid Image");
+ alert.setHeaderText(label + " was not accepted");
+ alert.setContentText(reason + " Allowed: JPG, JPEG, PNG; max size 2 MB; max dimensions " + com.auction.shared.Constants.MAX_IMAGE_WIDTH + "x" + com.auction.shared.Constants.MAX_IMAGE_HEIGHT + ".");
+ alert.showAndWait();
}
private void updateImagesLabel() {
@@ -308,6 +778,84 @@ private void updateImagesLabel() {
imagesLabel.setText(count + " images selected");
}
+ private void selectCreatedAuction(int id) {
+ try {
+ javafx.collections.ObservableList items = marketTable.getItems();
+ if (items != null) {
+ for (com.auction.shared.models.AuctionItem item : items) {
+ if (item != null && item.getId() == id) {
+ marketTable.getSelectionModel().select(item);
+ marketTable.scrollTo(item);
+ return;
+ }
+ }
+ }
+
+ items = myListingsTable.getItems();
+ if (items != null) {
+ for (com.auction.shared.models.AuctionItem item : items) {
+ if (item != null && item.getId() == id) {
+ myListingsTable.getSelectionModel().select(item);
+ myListingsTable.scrollTo(item);
+ return;
+ }
+ }
+ }
+ } catch (Exception ignored) {
+ }
+ }
+
+ private void showManualLaunchDialog(int auctionId) {
+ Alert alert = new Alert(Alert.AlertType.INFORMATION);
+ alert.setTitle("Auction Launched");
+ alert.setHeaderText("Auction launched successfully");
+ alert.setContentText("Your listing is now live and accepting bids.");
+
+ ButtonType viewAuction = new ButtonType("View auction", ButtonBar.ButtonData.OK_DONE);
+ ButtonType dashboard = new ButtonType("Go to Dashboard", ButtonBar.ButtonData.CANCEL_CLOSE);
+ alert.getButtonTypes().setAll(viewAuction, dashboard);
+
+ java.util.Optional choice = alert.showAndWait();
+ if (choice.isPresent() && choice.get() == viewAuction) {
+ openAuctionDetail(auctionId, "user_dashboard.fxml");
+ }
+ }
+
+ private void openAuctionDetail(int auctionId, String returnView) {
+ try {
+ com.auction.client.core.ClientContext context = com.auction.client.core.ClientContext.getInstance();
+ context.setCurrentAuctionId(auctionId);
+ context.setPreviousViewName(returnView);
+ Object controller = context.getViewLoader().loadView("auction_detail.fxml");
+ if (controller instanceof com.auction.client.controllers.AuctionDetailController detailController) {
+ detailController.setReturnViewName(returnView);
+ }
+ } catch (Exception e) {
+ if (statusLabel != null) {
+ statusLabel.setText("Unable to open auction detail: " + e.getMessage());
+ }
+ }
+ }
+
+ private static final class DashboardSnapshot {
+ private final java.util.List activeAuctions;
+ private final java.util.List mine;
+ private final java.util.List bids;
+ private final java.util.List won;
+
+ private DashboardSnapshot(
+ java.util.List activeAuctions,
+ java.util.List mine,
+ java.util.List bids,
+ java.util.List won
+ ) {
+ this.activeAuctions = activeAuctions == null ? java.util.List.of() : activeAuctions;
+ this.mine = mine == null ? java.util.List.of() : mine;
+ this.bids = bids == null ? java.util.List.of() : bids;
+ this.won = won == null ? java.util.List.of() : won;
+ }
+ }
+
@FXML
private void handleLogout() {
try {
diff --git a/src/main/java/com/auction/client/core/ClientContext.java b/src/main/java/com/auction/client/core/ClientContext.java
index d4b7729..08a9441 100644
--- a/src/main/java/com/auction/client/core/ClientContext.java
+++ b/src/main/java/com/auction/client/core/ClientContext.java
@@ -3,9 +3,22 @@
import com.auction.client.network.RmiClientProvider;
import com.auction.client.network.UdpDiscoveryClient;
import com.auction.client.ui.ViewLoader;
+import com.auction.shared.models.AuctionItem;
+import javafx.application.Platform;
+import javafx.scene.control.Alert;
+import javafx.stage.Modality;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
public class ClientContext {
private static final ClientContext INSTANCE = new ClientContext();
+ private static final long AUTO_LAUNCH_POLL_SECONDS = 2L;
private RmiClientProvider rmiProvider;
private UdpDiscoveryClient udpClient;
@@ -15,6 +28,15 @@ public class ClientContext {
private String username;
private String previousViewName;
private int currentAuctionId = -1;
+ private final ScheduledExecutorService automaticLaunchWatcher = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread thread = new Thread(r, "auto-launch-watcher");
+ thread.setDaemon(true);
+ return thread;
+ });
+ private final Map lastSeenAuctionStatus = new ConcurrentHashMap<>();
+ private final Set notifiedAuctionIds = ConcurrentHashMap.newKeySet();
+ private volatile boolean automaticLaunchWatcherRunning = false;
+ private volatile ScheduledFuture> automaticLaunchWatcherTask;
private ClientContext() {
rmiProvider = new RmiClientProvider();
@@ -31,7 +53,14 @@ public static ClientContext getInstance() {
public void setViewLoader(ViewLoader viewLoader) { this.viewLoader = viewLoader; }
public String getSessionToken() { return sessionToken; }
- public void setSessionToken(String sessionToken) { this.sessionToken = sessionToken; }
+ public void setSessionToken(String sessionToken) {
+ this.sessionToken = sessionToken;
+ if (sessionToken == null || sessionToken.isBlank()) {
+ stopAutomaticLaunchWatcher();
+ } else {
+ startAutomaticLaunchWatcher();
+ }
+ }
public String getUserRole() { return userRole; }
public void setUserRole(String userRole) { this.userRole = userRole; }
@@ -46,10 +75,14 @@ public static ClientContext getInstance() {
public void setCurrentAuctionId(int currentAuctionId) { this.currentAuctionId = currentAuctionId; }
public void clearSession() {
+ stopAutomaticLaunchWatcher();
this.sessionToken = null;
this.userRole = null;
this.username = null;
this.previousViewName = null;
+ this.currentAuctionId = -1;
+ this.lastSeenAuctionStatus.clear();
+ this.notifiedAuctionIds.clear();
}
public void handleConnectionLost() {
@@ -60,4 +93,68 @@ public void handleConnectionLost() {
e.printStackTrace();
}
}
+
+ private synchronized void startAutomaticLaunchWatcher() {
+ if (automaticLaunchWatcherRunning) {
+ return;
+ }
+ automaticLaunchWatcherRunning = true;
+ automaticLaunchWatcherTask = automaticLaunchWatcher.scheduleAtFixedRate(() -> {
+ try {
+ String token = sessionToken;
+ if (token == null || token.isBlank()) {
+ return;
+ }
+
+ java.util.List auctions = getRmiProvider().getService().getAllAuctions();
+ if (auctions == null || auctions.isEmpty()) {
+ return;
+ }
+
+ for (AuctionItem item : auctions) {
+ if (item == null || item.getId() <= 0) {
+ continue;
+ }
+
+ String currentStatus = item.getStatus();
+ String previousStatus = lastSeenAuctionStatus.put(item.getId(), currentStatus);
+ if (previousStatus == null) {
+ continue;
+ }
+
+ boolean becameActive = !com.auction.shared.Constants.STATUS_ACTIVE.equalsIgnoreCase(previousStatus)
+ && com.auction.shared.Constants.STATUS_ACTIVE.equalsIgnoreCase(currentStatus);
+ boolean automaticStart = com.auction.shared.Constants.START_MODE_AUTO.equalsIgnoreCase(item.getStartMode());
+
+ if (becameActive && automaticStart && notifiedAuctionIds.add(item.getId())) {
+ Platform.runLater(() -> showAutomaticLaunchNotice(item));
+ }
+ }
+ } catch (Exception ignored) {
+ // Best-effort background watcher; temporary failures should not interrupt the session.
+ }
+ }, AUTO_LAUNCH_POLL_SECONDS, AUTO_LAUNCH_POLL_SECONDS, TimeUnit.SECONDS);
+ }
+
+ private synchronized void stopAutomaticLaunchWatcher() {
+ automaticLaunchWatcherRunning = false;
+ ScheduledFuture> task = automaticLaunchWatcherTask;
+ if (task != null) {
+ task.cancel(false);
+ automaticLaunchWatcherTask = null;
+ }
+ }
+
+ private void showAutomaticLaunchNotice(AuctionItem item) {
+ Alert alert = new Alert(Alert.AlertType.INFORMATION);
+ alert.initModality(Modality.NONE);
+ alert.setTitle("Auction Launched");
+ alert.setHeaderText("Auction launched automatically");
+ alert.setContentText(
+ item == null
+ ? "An auction is now live and accepting bids."
+ : "Auction #" + item.getId() + " is now live and accepting bids."
+ );
+ alert.show();
+ }
}
diff --git a/src/main/java/com/auction/client/network/UdpDiscoveryClient.java b/src/main/java/com/auction/client/network/UdpDiscoveryClient.java
index 4a86484..f697e9d 100644
--- a/src/main/java/com/auction/client/network/UdpDiscoveryClient.java
+++ b/src/main/java/com/auction/client/network/UdpDiscoveryClient.java
@@ -4,6 +4,7 @@
import java.net.DatagramPacket;
import java.net.DatagramSocket;
+import java.net.InetSocketAddress;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
@@ -26,18 +27,32 @@ public String toString() {
private volatile boolean running;
private Thread listenerThread;
- /** Start listening for server broadcasts. */
- public void startListening() {
- if (running) return;
+ /**
+ * Start listening for server broadcasts.
+ * @return true if discovery listening started, false if the port is already in use.
+ */
+ public boolean startListening() {
+ if (running) return true;
running = true;
+ final DatagramSocket socket;
+ try {
+ socket = new DatagramSocket(null);
+ socket.setReuseAddress(true);
+ socket.bind(new InetSocketAddress(Constants.UDP_BROADCAST_PORT));
+ socket.setSoTimeout(1000); // 1 second timeout to allow interrupt checking
+ } catch (Exception e) {
+ running = false;
+ System.err.println("[RTDAS] UDP discovery unavailable: " + e.getMessage());
+ return false;
+ }
+
listenerThread = new Thread(() -> {
- try (DatagramSocket socket = new DatagramSocket(Constants.UDP_BROADCAST_PORT)) {
- socket.setSoTimeout(1000); // 1 second timeout to allow interrupt checking
+ try (DatagramSocket boundSocket = socket) {
byte[] buffer = new byte[1024];
while (running) {
try {
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
- socket.receive(packet);
+ boundSocket.receive(packet);
String data = new String(packet.getData(), 0, packet.getLength()).trim();
// Format: RTDAS||
if (data.startsWith(Constants.UDP_PREFIX + "|")) {
@@ -58,12 +73,13 @@ public void startListening() {
}
} catch (Exception e) {
if (running) {
- e.printStackTrace();
+ System.err.println("[RTDAS] UDP discovery unavailable: " + e.getMessage());
}
}
});
listenerThread.setDaemon(true);
listenerThread.start();
+ return true;
}
/** Stop listening. */
diff --git a/src/main/java/com/auction/client/service/BidHistoryService.java b/src/main/java/com/auction/client/service/BidHistoryService.java
new file mode 100644
index 0000000..9d3ccd6
--- /dev/null
+++ b/src/main/java/com/auction/client/service/BidHistoryService.java
@@ -0,0 +1,26 @@
+package com.auction.client.service;
+
+import com.auction.client.core.ClientContext;
+import com.auction.shared.models.Bid;
+
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+
+public final class BidHistoryService {
+
+ private BidHistoryService() {
+ }
+
+ public static CompletableFuture> loadBidHistoryAsync(int auctionId) {
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ return ClientContext.getInstance()
+ .getRmiProvider()
+ .getService()
+ .getBidHistory(auctionId);
+ } catch (Exception e) {
+ throw new java.util.concurrent.CompletionException(e);
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/auction/client/service/PollingService.java b/src/main/java/com/auction/client/service/PollingService.java
index fa6f647..e948ec7 100644
--- a/src/main/java/com/auction/client/service/PollingService.java
+++ b/src/main/java/com/auction/client/service/PollingService.java
@@ -5,11 +5,13 @@
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
/**
- * Polls the server every 2 seconds for auction updates.
+ * Polls the server every 1 second for auction updates.
* Results dispatched to a callback (should use Platform.runLater in controller).
* Auto-stops when shutdown() is called.
*/
@@ -17,9 +19,28 @@ public class PollingService {
private final IAuctionService service;
private ScheduledExecutorService scheduler;
+ private ScheduledFuture> scheduledTask;
+ private volatile boolean paused = false;
+ private final AtomicInteger failureCount = new AtomicInteger(0);
+ private final int baseIntervalSeconds;
+ private final int maxBackoffSeconds;
+ private final int maxFailuresBeforeNotify;
+ private Consumer onFailure;
+ private Consumer onUpdateCallback;
+ private volatile boolean running = false;
public PollingService(IAuctionService service) {
+ this(service, 1, 3, 32);
+ }
+
+ /**
+ * Constructor with tunable timing for tests.
+ */
+ public PollingService(IAuctionService service, int baseIntervalSeconds, int maxFailuresBeforeNotify, int maxBackoffSeconds) {
this.service = service;
+ this.baseIntervalSeconds = Math.max(1, baseIntervalSeconds);
+ this.maxFailuresBeforeNotify = Math.max(1, maxFailuresBeforeNotify);
+ this.maxBackoffSeconds = Math.max(baseIntervalSeconds, maxBackoffSeconds);
}
/**
@@ -28,23 +49,67 @@ public PollingService(IAuctionService service) {
* @param onUpdate callback with updated AuctionItem (called on background thread)
*/
public void startPolling(int auctionId, Consumer onUpdate) {
- // TODO: schedule getAuctionById every 2s, call onUpdate with result
+ startPolling(auctionId, onUpdate, null);
+ }
+
+ /**
+ * Start polling with optional failure callback. onFailure is invoked after
+ * {@code maxFailuresBeforeNotify} consecutive errors.
+ */
+ public void startPolling(int auctionId, Consumer onUpdate, Consumer onFailure) {
+ if (running) return;
+ this.onFailure = onFailure;
+ this.onUpdateCallback = onUpdate;
scheduler = Executors.newSingleThreadScheduledExecutor();
- scheduler.scheduleAtFixedRate(() -> {
- try{
+ running = true;
+ scheduleNext(0, auctionId);
+ }
+
+ private void scheduleNext(long delaySeconds, int auctionId) {
+ scheduledTask = scheduler.schedule(() -> {
+ if (!running) return;
+ if (paused) {
+ // If paused, reschedule without touching failure counters
+ scheduleNext(baseIntervalSeconds, auctionId);
+ return;
+ }
+ try {
AuctionItem item = service.getAuctionById(auctionId);
- onUpdate.accept(item);
- } catch(Exception e){
- System.err.println(e.getMessage());
- e.printStackTrace();
+ failureCount.set(0);
+ if (onUpdateCallback != null) onUpdateCallback.accept(item);
+ scheduleNext(baseIntervalSeconds, auctionId);
+ } catch (Exception e) {
+ int fails = failureCount.incrementAndGet();
+ if (fails >= maxFailuresBeforeNotify && onFailure != null) {
+ onFailure.accept(e);
+ }
+ // exponential backoff
+ long nextDelay = Math.min(maxBackoffSeconds, baseIntervalSeconds * (1L << Math.min(30, fails)));
+ scheduleNext(nextDelay, auctionId);
}
- }, 0, 2, TimeUnit.SECONDS);
+ }, delaySeconds, TimeUnit.SECONDS);
}
/** Stop all polling. Call when leaving the detail view. */
public void shutdown() {
+ running = false;
+ paused = false;
+ if (scheduledTask != null) scheduledTask.cancel(true);
if (scheduler != null && !scheduler.isShutdown()) {
scheduler.shutdownNow();
}
}
+
+ /** Pause polling (keeps scheduler alive). */
+ public void pause() { this.paused = true; }
+
+ /** Resume polling after a pause. */
+ public void resume() { this.paused = false; }
+
+ public boolean isPaused() { return this.paused; }
+
+ public boolean isRunning() { return this.running; }
+
+ /** Set a failure callback after startPolling if needed. */
+ public void setOnFailure(Consumer onFailure) { this.onFailure = onFailure; }
}
diff --git a/src/main/java/com/auction/client/ui/ViewLoader.java b/src/main/java/com/auction/client/ui/ViewLoader.java
index 9784cc9..e5d1a17 100644
--- a/src/main/java/com/auction/client/ui/ViewLoader.java
+++ b/src/main/java/com/auction/client/ui/ViewLoader.java
@@ -4,6 +4,8 @@
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;
+import com.auction.client.controllers.AuctionDetailController;
+import com.auction.client.core.ClientContext;
import java.io.IOException;
import java.net.URL;
@@ -45,6 +47,17 @@ public T loadView(String fxmlFile) throws IOException {
Scene scene = new Scene(root);
scene.getStylesheets().add(getClass().getResource("/css/style.css").toExternalForm());
primaryStage.setScene(scene);
+ // If an AuctionDetailController was loaded, ensure it gets a deterministic return target
+ Object ctrl = loader.getController();
+ try {
+ if (ctrl instanceof AuctionDetailController) {
+ AuctionDetailController adc = (AuctionDetailController) ctrl;
+ String prev = ClientContext.getInstance().getPreviousViewName();
+ if (prev != null && !prev.isBlank()) {
+ adc.setReturnViewName(prev);
+ }
+ }
+ } catch (Exception ignored) {}
return loader.getController();
}
diff --git a/src/main/java/com/auction/server/core/AuctionManager.java b/src/main/java/com/auction/server/core/AuctionManager.java
index 9f22bc5..9afda7f 100644
--- a/src/main/java/com/auction/server/core/AuctionManager.java
+++ b/src/main/java/com/auction/server/core/AuctionManager.java
@@ -32,6 +32,18 @@ public List getActiveAuctions() {
return auctionRepo.findActiveAuctions();
}
+ public List getAllAuctions() {
+ return auctionRepo.findAllAuctions();
+ }
+
+ public List searchActiveAuctions(String query, String category, String sortBy) {
+ return auctionRepo.searchActiveAuctions(query, category, sortBy);
+ }
+
+ public List searchAllAuctions(String query, String category, String sortBy) {
+ return auctionRepo.searchAllAuctions(query, category, sortBy);
+ }
+
public List findActiveAuctionsBySeller(String sellerUsername) {
return auctionRepo.findAuctionsBySeller(sellerUsername).stream()
.filter(a -> Constants.STATUS_ACTIVE.equals(a.getStatus()))
@@ -97,7 +109,26 @@ public void placeBid(int auctionId, SessionContext user, long amountCents, long
public int createAuction(AuctionItem item, SessionContext user, String[] imagePaths) throws Exception {
item.setSellerUsername(user.username());
- item.setStatus(Constants.STATUS_ACTIVE);
+ if (item.getStartMode() == null || item.getStartMode().isBlank()) {
+ item.setStartMode(Constants.START_MODE_AUTO);
+ }
+
+ // Honor manual start intent: manual auctions remain scheduled until user starts them.
+ if (Constants.START_MODE_MANUAL.equalsIgnoreCase(item.getStartMode())) {
+ item.setStatus(Constants.STATUS_SCHEDULED);
+ } else {
+ // AUTO mode: scheduled if startTime is in the future, otherwise active immediately.
+ if (item.getStartTime() != null) {
+ Instant start = Instant.parse(item.getStartTime());
+ if (Instant.now().isBefore(start)) {
+ item.setStatus(Constants.STATUS_SCHEDULED);
+ } else {
+ item.setStatus(Constants.STATUS_ACTIVE);
+ }
+ } else {
+ item.setStatus(Constants.STATUS_ACTIVE);
+ }
+ }
if (item.getEndTime() != null) {
Instant endTime = Instant.parse(item.getEndTime());
Instant capEndTime = endTime.plus(Duration.ofMinutes(Constants.SNIPE_CAP_DEFAULT_MINUTES));
@@ -117,6 +148,76 @@ public int createAuction(AuctionItem item, SessionContext user, String[] imagePa
});
}
+ public void updateAuction(int auctionId, AuctionItem updatedItem, SessionContext user, String[] imagePaths) throws Exception {
+ lockManager.lock(auctionId);
+ try {
+ txManager.executeWithoutResult(() -> {
+ AuctionItem current = auctionRepo.findAuctionById(auctionId);
+ if (current == null) throw new AuctionException("Auction not found");
+
+ if (!current.getSellerUsername().equals(user.username()) && !Constants.ADMIN.equals(user.role())) {
+ throw new AuctionException("Only the seller or an admin can edit this auction");
+ }
+
+ if (!Constants.STATUS_SCHEDULED.equals(current.getStatus())) {
+ throw new AuctionException("Only scheduled auctions can be edited before they start");
+ }
+
+ if (bidRepo.countBidsByAuctionId(auctionId) > 0) {
+ throw new AuctionException("Cannot edit an auction that already has bids");
+ }
+
+ if (updatedItem == null) {
+ throw new AuctionException("Auction update data is missing");
+ }
+
+ if (updatedItem.getTitle() == null || updatedItem.getTitle().isBlank()) {
+ throw new AuctionException("Title is required");
+ }
+ if (updatedItem.getCategory() == null || updatedItem.getCategory().isBlank()) {
+ throw new AuctionException("Category is required");
+ }
+ if (updatedItem.getDescription() == null) {
+ updatedItem.setDescription("");
+ }
+
+ Instant start = Instant.parse(updatedItem.getStartTime());
+ Instant end = Instant.parse(updatedItem.getEndTime());
+ if (!end.isAfter(start)) {
+ throw new AuctionException("End time must be after start time");
+ }
+
+ AuctionItem toSave = new AuctionItem();
+ toSave.setId(auctionId);
+ toSave.setTitle(updatedItem.getTitle().trim());
+ toSave.setDescription(updatedItem.getDescription());
+ toSave.setCategory(updatedItem.getCategory().trim());
+ toSave.setStartingPriceCents(updatedItem.getStartingPriceCents());
+ toSave.setCurrentBidCents(updatedItem.getStartingPriceCents());
+ toSave.setHighestBidderUsername(null);
+ toSave.setSellerUsername(current.getSellerUsername());
+ toSave.setStartTime(updatedItem.getStartTime());
+ toSave.setEndTime(updatedItem.getEndTime());
+ toSave.setCapEndTime(updatedItem.getCapEndTime());
+ toSave.setStatus(Constants.STATUS_SCHEDULED);
+ toSave.setStartMode(updatedItem.getStartMode() == null || updatedItem.getStartMode().isBlank()
+ ? current.getStartMode()
+ : updatedItem.getStartMode());
+ toSave.setMinIncrementPercent(updatedItem.getMinIncrementPercent());
+ toSave.setRelistedFrom(current.getRelistedFrom());
+ toSave.setImg1(imagePaths != null && imagePaths.length > 0 && imagePaths[0] != null ? imagePaths[0] : current.getImg1());
+ toSave.setImg2(imagePaths != null && imagePaths.length > 1 && imagePaths[1] != null ? imagePaths[1] : current.getImg2());
+ toSave.setImg3(imagePaths != null && imagePaths.length > 2 && imagePaths[2] != null ? imagePaths[2] : current.getImg3());
+
+ auctionRepo.updateAuctionDetails(toSave);
+ AsyncLogger.log(LogCategory.AUDIT, EventType.CREATE_AUCTION,
+ "User=" + user.username() + " EditedAuction=" + auctionId);
+ });
+ } finally {
+ lockManager.unlock(auctionId);
+ }
+ }
+
public void cancelAuction(int auctionId, SessionContext user) throws Exception {
lockManager.lock(auctionId);
try {
@@ -177,6 +278,7 @@ public int relistAuction(int auctionId, String newEndTimeIso, SessionContext use
child.setEndTime(newEndTimeIso);
child.setCapEndTime(newEndTime.plus(Duration.ofMinutes(Constants.SNIPE_CAP_DEFAULT_MINUTES)).toString());
child.setStatus(Constants.STATUS_ACTIVE);
+ child.setStartMode(Constants.START_MODE_AUTO);
child.setImg1(parent.getImg1());
child.setImg2(parent.getImg2());
child.setImg3(parent.getImg3());
@@ -192,6 +294,76 @@ public int relistAuction(int auctionId, String newEndTimeIso, SessionContext use
}
}
+ public void startAuction(int auctionId, SessionContext user) throws Exception {
+ lockManager.lock(auctionId);
+ try {
+ txManager.executeWithoutResult(() -> {
+ AuctionItem item = auctionRepo.findAuctionById(auctionId);
+ if (item == null) throw new AuctionException("Auction not found");
+
+ if (!item.getSellerUsername().equals(user.username()) && !Constants.ADMIN.equals(user.role())) {
+ throw new AuctionException("Only the seller or an admin can start this auction");
+ }
+
+ if (!Constants.STATUS_SCHEDULED.equals(item.getStatus())) {
+ throw new AuctionException("Only scheduled auctions can be started manually");
+ }
+ // Only allow manual start if the auction was created with manual start mode
+ String mode = item.getStartMode();
+ if (mode == null || !Constants.START_MODE_MANUAL.equalsIgnoreCase(mode)) {
+ throw new AuctionException("Auction is not configured for manual start");
+ }
+
+ if (item.getStartTime() == null || item.getStartTime().isBlank()) {
+ throw new AuctionException("Auction start time is missing");
+ }
+
+ Instant start = Instant.parse(item.getStartTime());
+ Instant manualCutoff = start.plus(Duration.ofMinutes(5));
+ Instant now = Instant.now();
+
+ if (now.isAfter(manualCutoff)) {
+ auctionRepo.updateAuctionStatus(auctionId, Constants.STATUS_CANCELLED);
+ AsyncLogger.log(LogCategory.SYSTEM, EventType.CANCEL_AUCTION,
+ "Auction=" + auctionId + " Reason=manual-start-time-expired");
+ throw new AuctionException("Manual start window expired. Please update the start time and try again.");
+ }
+
+ Instant startInstant = Instant.parse(item.getStartTime());
+ Instant endInstant = Instant.parse(item.getEndTime());
+ java.time.Duration auctionDuration = java.time.Duration.between(startInstant, endInstant);
+ if (auctionDuration.isNegative() || auctionDuration.isZero()) {
+ auctionDuration = java.time.Duration.ofMinutes(1);
+ }
+
+ // Activate auction
+ auctionRepo.updateAuctionStatus(auctionId, Constants.STATUS_ACTIVE);
+ Instant activatedStart = now;
+ Instant activatedEnd = activatedStart.plus(auctionDuration);
+ auctionRepo.updateAuctionStartTime(auctionId, activatedStart.toString());
+ auctionRepo.updateAuctionEndTime(auctionId, activatedEnd.toString());
+
+ if (item.getCapEndTime() != null && !item.getCapEndTime().isBlank()) {
+ Instant originalCap = Instant.parse(item.getCapEndTime());
+ java.time.Duration capGap = java.time.Duration.between(endInstant, originalCap);
+ Instant newCap = activatedEnd.plus(capGap.isNegative() ? java.time.Duration.ZERO : capGap);
+ auctionRepo.updateAuctionCapEndTime(auctionId, newCap.toString());
+ item.setCapEndTime(newCap.toString());
+ } else {
+ Instant newCap = activatedEnd.plus(java.time.Duration.ofMinutes(Constants.SNIPE_CAP_DEFAULT_MINUTES));
+ auctionRepo.updateAuctionCapEndTime(auctionId, newCap.toString());
+ item.setCapEndTime(newCap.toString());
+ }
+ item.setStartTime(activatedStart.toString());
+ item.setEndTime(activatedEnd.toString());
+ item.setStatus(Constants.STATUS_ACTIVE);
+ AsyncLogger.log(LogCategory.AUDIT, EventType.RELIST_AUCTION, "User=" + user.username() + " StartedScheduledAuction=" + auctionId);
+ });
+ } finally {
+ lockManager.unlock(auctionId);
+ }
+ }
+
private void validateActive(AuctionItem item) throws AuctionClosedException {
if (!Constants.STATUS_ACTIVE.equals(item.getStatus())) {
throw new AuctionClosedException("Auction is closed");
@@ -225,9 +397,13 @@ private void validateMinimumBid(AuctionItem item, long amountCents) throws Insuf
throw new InsufficientBidException("Bid must be at least the starting price of " + Constants.formatCents(item.getStartingPriceCents()));
}
} else {
- long minBidCents = item.getCurrentBidCents() + (item.getCurrentBidCents() + 19) / 20;
+ double percent = item.getMinIncrementPercent();
+ if (percent <= 0) percent = Constants.MIN_BID_INCREMENT_PERCENT;
+ long current = item.getCurrentBidCents();
+ long increment = Math.max(1, Math.round(current * percent));
+ long minBidCents = current + increment;
if (amountCents < minBidCents) {
- throw new InsufficientBidException("Bid must be at least " + Constants.formatCents(minBidCents) + " (5% increment)");
+ throw new InsufficientBidException("Bid must be at least " + Constants.formatCents(minBidCents) + " (" + String.format("%.2f", percent * 100) + "% increment)");
}
}
}
diff --git a/src/main/java/com/auction/server/core/ImageStore.java b/src/main/java/com/auction/server/core/ImageStore.java
index 950614f..16fc618 100644
--- a/src/main/java/com/auction/server/core/ImageStore.java
+++ b/src/main/java/com/auction/server/core/ImageStore.java
@@ -1,6 +1,13 @@
package com.auction.server.core;
-import com.auction.server.repository.AuctionRepository;
+import com.auction.shared.Constants;
+import javax.imageio.ImageIO;
+import java.awt.AlphaComposite;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -14,11 +21,8 @@
public class ImageStore {
private static final String IMAGES_DIR = "data/images";
private static final String THUMBS_DIR = "data/thumbs";
-
- private final AuctionRepository auctionRepo;
- public ImageStore(AuctionRepository auctionRepo) {
- this.auctionRepo = auctionRepo;
+ public ImageStore() {
ensureDirectoriesExist();
}
@@ -37,9 +41,9 @@ private void ensureDirectoriesExist() {
*/
public String[] stageImages(byte[] i1, byte[] i2, byte[] i3) {
String baseId = java.util.UUID.randomUUID().toString();
- String p1 = saveToDisk(baseId, 1, i1, true);
- String p2 = saveToDisk(baseId, 2, i2, false);
- String p3 = saveToDisk(baseId, 3, i3, false);
+ String p1 = saveToDisk(baseId, 1, i1);
+ String p2 = saveToDisk(baseId, 2, i2);
+ String p3 = saveToDisk(baseId, 3, i3);
return new String[]{p1, p2, p3};
}
@@ -53,10 +57,8 @@ public void deleteStagedImages(String[] paths) {
if (pathStr != null) {
try {
Files.deleteIfExists(Paths.get(pathStr));
- // Also try to delete thumb if it was the first image
- if (pathStr.endsWith("_1.jpg")) {
- String thumbName = Paths.get(pathStr).getFileName().toString().replace(".jpg", "_thumb.jpg");
- Files.deleteIfExists(Paths.get(THUMBS_DIR, thumbName));
+ if (pathStr.contains("_1.")) {
+ Files.deleteIfExists(getThumbPath(pathStr));
}
} catch (IOException e) {
System.err.println("Failed to delete orphaned image: " + pathStr);
@@ -78,9 +80,25 @@ public byte[] loadFullImage(String path) {
*/
public byte[] loadThumbnail(String img1Path) {
if (img1Path == null) return new byte[0];
- String thumbName = Paths.get(img1Path).getFileName().toString().replace(".jpg", "_thumb.jpg");
- Path path = Paths.get(THUMBS_DIR, thumbName);
- return readBytes(path.toString());
+ Path thumbPath = getThumbPath(img1Path);
+ byte[] thumb = readBytes(thumbPath.toString());
+ if (thumb.length > 0) {
+ return thumb;
+ }
+
+ byte[] full = readBytes(img1Path);
+ if (full.length == 0) {
+ return new byte[0];
+ }
+
+ try {
+ ImagePayload payload = normalizeImage(full);
+ Files.write(thumbPath, payload.thumbBytes);
+ return payload.thumbBytes;
+ } catch (Exception e) {
+ System.err.println("Failed to synthesize thumbnail for: " + img1Path + " - " + e.getMessage());
+ return new byte[0];
+ }
}
private byte[] readBytes(String pathStr) {
@@ -96,24 +114,144 @@ private byte[] readBytes(String pathStr) {
return new byte[0];
}
- private String saveToDisk(String baseId, int index, byte[] data, boolean generateThumb) {
+ private String saveToDisk(String baseId, int index, byte[] data) {
if (data == null || data.length == 0) return null;
-
- String filename = baseId + "_" + index + ".jpg";
- Path path = Paths.get(IMAGES_DIR, filename);
try {
- Files.write(path, data);
-
- if (generateThumb) {
- // Simplified: just copy to thumb dir for now, or imagine a resize logic here
- Path thumbPath = Paths.get(THUMBS_DIR, baseId + "_" + index + "_thumb.jpg");
- Files.copy(path, thumbPath, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
- }
-
+ ImagePayload payload = normalizeImage(data);
+
+ String filename = baseId + "_" + index + ".jpg";
+ Path path = Paths.get(IMAGES_DIR, filename);
+ Files.write(path, payload.fullBytes);
+ Path thumbPath = Paths.get(THUMBS_DIR, baseId + "_" + index + "_thumb.jpg");
+ Files.write(thumbPath, payload.thumbBytes);
+
return path.toString();
+ } catch (IllegalArgumentException e) {
+ System.err.println("Failed to process image for storage: " + e.getMessage());
+ return null;
} catch (IOException e) {
- System.err.println("Failed to save image " + filename + ": " + e.getMessage());
+ System.err.println("Failed to save image: " + e.getMessage());
return null;
}
}
+
+ private Path getThumbPath(String imagePath) {
+ Path source = Paths.get(imagePath);
+ String fileName = source.getFileName().toString();
+ int dot = fileName.lastIndexOf('.');
+ String baseName = dot >= 0 ? fileName.substring(0, dot) : fileName;
+ return Paths.get(THUMBS_DIR, baseName + "_thumb.jpg");
+ }
+
+ private ImagePayload normalizeImage(byte[] input) {
+ if (input.length > Constants.MAX_IMAGE_SIZE_BYTES) {
+ throw new IllegalArgumentException("Image must be 2 MB or smaller.");
+ }
+
+ BufferedImage decoded;
+ try {
+ decoded = ImageIO.read(new ByteArrayInputStream(input));
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Could not read image: " + e.getMessage(), e);
+ }
+ if (decoded == null) {
+ throw new IllegalArgumentException("Unsupported image format. Use JPG, JPEG, or PNG.");
+ }
+
+ if (decoded.getWidth() > Constants.MAX_IMAGE_WIDTH || decoded.getHeight() > Constants.MAX_IMAGE_HEIGHT) {
+ throw new IllegalArgumentException(
+ "Image dimensions must be at most " +
+ Constants.MAX_IMAGE_WIDTH +
+ "x" +
+ Constants.MAX_IMAGE_HEIGHT +
+ " pixels."
+ );
+ }
+
+ String format = detectFormatName(input);
+ if (format != null && !Constants.SUPPORTED_IMAGE_FORMATS.contains(format.toLowerCase())) {
+ throw new IllegalArgumentException("Unsupported image format. Use JPG, JPEG, or PNG.");
+ }
+
+ BufferedImage rgb = new BufferedImage(decoded.getWidth(), decoded.getHeight(), BufferedImage.TYPE_INT_RGB);
+ Graphics2D g = rgb.createGraphics();
+ try {
+ g.setComposite(AlphaComposite.Src);
+ g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
+ g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ g.drawImage(decoded, 0, 0, java.awt.Color.WHITE, null);
+ } finally {
+ g.dispose();
+ }
+
+ BufferedImage thumb = createThumbnail(rgb, 360);
+ return new ImagePayload(writeJpeg(rgb), writeJpeg(thumb));
+ }
+
+ private BufferedImage createThumbnail(BufferedImage source, int maxDimension) {
+ int width = source.getWidth();
+ int height = source.getHeight();
+ if (width <= maxDimension && height <= maxDimension) {
+ return source;
+ }
+
+ double scale = Math.min((double) maxDimension / width, (double) maxDimension / height);
+ int targetWidth = Math.max(1, (int) Math.round(width * scale));
+ int targetHeight = Math.max(1, (int) Math.round(height * scale));
+
+ BufferedImage scaled = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
+ Graphics2D g = scaled.createGraphics();
+ try {
+ g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
+ g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ g.drawImage(source, 0, 0, targetWidth, targetHeight, null);
+ } finally {
+ g.dispose();
+ }
+ return scaled;
+ }
+
+ private byte[] writeJpeg(BufferedImage image) {
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ if (!ImageIO.write(image, "jpg", out)) {
+ throw new IllegalStateException("Could not encode image as JPEG");
+ }
+ return out.toByteArray();
+ } catch (IOException e) {
+ throw new IllegalStateException("Could not encode image as JPEG", e);
+ }
+ }
+
+ private String detectFormatName(byte[] input) {
+ try (javax.imageio.stream.ImageInputStream stream = ImageIO.createImageInputStream(new ByteArrayInputStream(input))) {
+ if (stream == null) {
+ return null;
+ }
+ java.util.Iterator readers = ImageIO.getImageReaders(stream);
+ if (!readers.hasNext()) {
+ return null;
+ }
+ javax.imageio.ImageReader reader = readers.next();
+ try {
+ return reader.getFormatName();
+ } finally {
+ reader.dispose();
+ }
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Could not inspect image format: " + e.getMessage(), e);
+ }
+ }
+
+ private static final class ImagePayload {
+ private final byte[] fullBytes;
+ private final byte[] thumbBytes;
+
+ private ImagePayload(byte[] fullBytes, byte[] thumbBytes) {
+ this.fullBytes = fullBytes;
+ this.thumbBytes = thumbBytes;
+ }
+ }
}
diff --git a/src/main/java/com/auction/server/core/LifecycleManager.java b/src/main/java/com/auction/server/core/LifecycleManager.java
index 3b84593..e28b919 100644
--- a/src/main/java/com/auction/server/core/LifecycleManager.java
+++ b/src/main/java/com/auction/server/core/LifecycleManager.java
@@ -9,6 +9,7 @@
import com.auction.server.core.logging.EventType;
import java.time.Instant;
+import java.time.Duration;
import java.util.List;
public class LifecycleManager {
@@ -59,4 +60,63 @@ public void sweepOverdue() {
}
}
}
+
+ public void activateScheduled() {
+ String nowTimeIso = Instant.now().toString();
+
+ // Manual auctions get a 5-minute grace window; after that they are cancelled
+ // unless the seller updates the start time.
+ String manualCutoffIso = Instant.now().minus(Duration.ofMinutes(5)).toString();
+ List overdueManual = auctionRepo.findManualScheduledAuctionsOverdue(manualCutoffIso);
+ for (AuctionItem item : overdueManual) {
+ int auctionId = item.getId();
+ lockManager.lock(auctionId);
+ try {
+ txManager.executeWithoutResult(() -> {
+ AuctionItem current = auctionRepo.findAuctionById(auctionId);
+ if (current != null
+ && Constants.STATUS_SCHEDULED.equals(current.getStatus())
+ && Constants.START_MODE_MANUAL.equalsIgnoreCase(current.getStartMode())
+ && current.getStartTime() != null
+ && Instant.now().isAfter(Instant.parse(current.getStartTime()).plus(Duration.ofMinutes(5)))) {
+ auctionRepo.updateAuctionStatus(auctionId, Constants.STATUS_CANCELLED);
+ AsyncLogger.log(LogCategory.SYSTEM, EventType.CANCEL_AUCTION,
+ "Auction=" + auctionId + " Reason=manual-start-time-expired");
+ }
+ });
+ } catch (Exception e) {
+ System.err.println("Error cancelling overdue manual auction " + auctionId + ": " + e.getMessage());
+ } finally {
+ lockManager.unlock(auctionId);
+ }
+ }
+
+ List scheduled = auctionRepo.findScheduledAuctionsToStart(nowTimeIso);
+ for (AuctionItem item : scheduled) {
+ int auctionId = item.getId();
+ lockManager.lock(auctionId);
+ try {
+ txManager.executeWithoutResult(() -> {
+ AuctionItem current = auctionRepo.findAuctionById(auctionId);
+ if (current != null && Constants.STATUS_SCHEDULED.equals(current.getStatus())) {
+ // Only activate if start_time has arrived
+ if (Instant.now().isAfter(Instant.parse(current.getStartTime())) || Instant.now().equals(Instant.parse(current.getStartTime()))) {
+ // ensure capEndTime exists
+ if (current.getEndTime() != null) {
+ Instant cap = Instant.parse(current.getEndTime()).plus(java.time.Duration.ofMinutes(Constants.SNIPE_CAP_DEFAULT_MINUTES));
+ current.setCapEndTime(cap.toString());
+ }
+ auctionRepo.updateAuctionStatus(auctionId, Constants.STATUS_ACTIVE);
+ AsyncLogger.log(com.auction.server.core.logging.LogCategory.SYSTEM, com.auction.server.core.logging.EventType.AUCTION_SCHEDULED_STARTED,
+ "Auction=" + auctionId + " started automatically at " + nowTimeIso);
+ }
+ }
+ });
+ } catch (Exception e) {
+ System.err.println("Error activating scheduled auction " + auctionId + ": " + e.getMessage());
+ } finally {
+ lockManager.unlock(auctionId);
+ }
+ }
+ }
}
diff --git a/src/main/java/com/auction/server/core/ServerBootstrap.java b/src/main/java/com/auction/server/core/ServerBootstrap.java
index af42587..9fc27b8 100644
--- a/src/main/java/com/auction/server/core/ServerBootstrap.java
+++ b/src/main/java/com/auction/server/core/ServerBootstrap.java
@@ -37,7 +37,7 @@ public ServerBootstrap() throws Exception {
AuctionManager auctionManager = new AuctionManager(auctionRepo, bidRepo, lockManager, txManager);
LifecycleManager lifecycleManager = new LifecycleManager(auctionRepo, bidRepo, lockManager, txManager);
- ImageStore imageStore = new ImageStore(auctionRepo);
+ ImageStore imageStore = new ImageStore();
// 4. Init Service
this.service = new AuctionServiceImpl(userRepo, auctionManager, lifecycleManager, imageStore);
diff --git a/src/main/java/com/auction/server/core/logging/EventType.java b/src/main/java/com/auction/server/core/logging/EventType.java
index 1c22a16..f9a4eee 100644
--- a/src/main/java/com/auction/server/core/logging/EventType.java
+++ b/src/main/java/com/auction/server/core/logging/EventType.java
@@ -11,6 +11,7 @@ public enum EventType {
OUTBID,
AUCTION_EXPIRED,
AUCTION_SOLD,
+ AUCTION_SCHEDULED_STARTED,
SERVER_START,
SERVER_STOP,
IMAGE_SAVE_FAILED,
diff --git a/src/main/java/com/auction/server/repository/AuctionRepository.java b/src/main/java/com/auction/server/repository/AuctionRepository.java
index 08b8797..134743c 100644
--- a/src/main/java/com/auction/server/repository/AuctionRepository.java
+++ b/src/main/java/com/auction/server/repository/AuctionRepository.java
@@ -14,8 +14,8 @@ public AuctionRepository(Connection connection) {
public int insertAuction(AuctionItem item) {
String sql = "INSERT INTO auction_items (title, description, category, starting_price_cents, current_bid_cents, " +
- "seller_username, start_time, end_time, cap_end_time, status, img1, img2, img3, relisted_from) " +
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+ "seller_username, start_time, end_time, cap_end_time, status, start_mode, min_increment_percent, img1, img2, img3, relisted_from) " +
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
try (var pstmt = connection.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS)) {
pstmt.setString(1, item.getTitle());
pstmt.setString(2, item.getDescription());
@@ -27,13 +27,15 @@ public int insertAuction(AuctionItem item) {
pstmt.setString(8, item.getEndTime());
pstmt.setString(9, item.getCapEndTime());
pstmt.setString(10, item.getStatus());
- pstmt.setString(11, item.getImg1());
- pstmt.setString(12, item.getImg2());
- pstmt.setString(13, item.getImg3());
+ pstmt.setString(11, item.getStartMode() == null ? com.auction.shared.Constants.START_MODE_AUTO : item.getStartMode());
+ pstmt.setDouble(12, item.getMinIncrementPercent());
+ pstmt.setString(13, item.getImg1());
+ pstmt.setString(14, item.getImg2());
+ pstmt.setString(15, item.getImg3());
if (item.getRelistedFrom() != null) {
- pstmt.setInt(14, item.getRelistedFrom());
+ pstmt.setInt(16, item.getRelistedFrom());
} else {
- pstmt.setNull(14, java.sql.Types.INTEGER);
+ pstmt.setNull(16, java.sql.Types.INTEGER);
}
pstmt.executeUpdate();
try (var rs = pstmt.getGeneratedKeys()) {
@@ -63,9 +65,14 @@ private AuctionItem mapRowToAuction(java.sql.ResultSet rs) throws java.sql.SQLEx
item.setEndTime(rs.getString("end_time"));
item.setCapEndTime(rs.getString("cap_end_time"));
item.setStatus(rs.getString("status"));
+ item.setStartMode(rs.getString("start_mode"));
item.setImg1(rs.getString("img1"));
item.setImg2(rs.getString("img2"));
item.setImg3(rs.getString("img3"));
+ try {
+ double p = rs.getDouble("min_increment_percent");
+ if (!rs.wasNull()) item.setMinIncrementPercent(p);
+ } catch (Exception ignored) {}
int relistedFromVal = rs.getInt("relisted_from");
item.setRelistedFrom(rs.wasNull() ? null : relistedFromVal);
return item;
@@ -86,7 +93,7 @@ public AuctionItem findAuctionById(int id) {
public List findActiveAuctions() {
List list = new java.util.ArrayList<>();
- String sql = "SELECT * FROM auction_items WHERE status = 'ACTIVE'";
+ String sql = "SELECT * FROM auction_items WHERE status IN ('ACTIVE','SCHEDULED')";
try (var stmt = connection.createStatement();
var rs = stmt.executeQuery(sql)) {
while (rs.next()) list.add(mapRowToAuction(rs));
@@ -96,6 +103,104 @@ public List findActiveAuctions() {
return list;
}
+ public List findAllAuctions() {
+ List list = new java.util.ArrayList<>();
+ String sql = "SELECT * FROM auction_items";
+ try (var stmt = connection.createStatement();
+ var rs = stmt.executeQuery(sql)) {
+ while (rs.next()) list.add(mapRowToAuction(rs));
+ } catch (java.sql.SQLException e) {
+ throw new RuntimeException("Failed to fetch all auctions", e);
+ }
+ return list;
+ }
+
+ public List searchActiveAuctions(String query, String category, String sortBy) {
+ List list = new java.util.ArrayList<>();
+
+ StringBuilder sql = new StringBuilder("SELECT * FROM auction_items WHERE status IN ('ACTIVE','SCHEDULED')");
+ java.util.List