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 params = new java.util.ArrayList<>(); + + if (query != null && !query.trim().isBlank()) { + sql.append(" AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(category) LIKE ?)"); + String like = "%" + query.trim().toLowerCase() + "%"; + params.add(like); + params.add(like); + params.add(like); + } + + if (category != null && !category.trim().isBlank()) { + sql.append(" AND LOWER(category) = LOWER(?)"); + params.add(category.trim()); + } + + String orderBy; + switch (sortBy == null ? "" : sortBy) { + case "price_asc" -> orderBy = " ORDER BY current_bid_cents ASC, end_time ASC"; + case "price_desc" -> orderBy = " ORDER BY current_bid_cents DESC, end_time ASC"; + default -> orderBy = " ORDER BY end_time DESC"; + } + sql.append(orderBy); + + try (var pstmt = connection.prepareStatement(sql.toString())) { + for (int i = 0; i < params.size(); i++) { + pstmt.setObject(i + 1, params.get(i)); + } + try (var rs = pstmt.executeQuery()) { + while (rs.next()) { + list.add(mapRowToAuction(rs)); + } + } + } catch (java.sql.SQLException e) { + throw new RuntimeException("Failed to search active auctions", e); + } + + return list; + } + + public List searchAllAuctions(String query, String category, String sortBy) { + List list = new java.util.ArrayList<>(); + + StringBuilder sql = new StringBuilder("SELECT * FROM auction_items WHERE 1=1"); + java.util.List params = new java.util.ArrayList<>(); + + if (query != null && !query.trim().isBlank()) { + sql.append(" AND (LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR LOWER(category) LIKE ?)"); + String like = "%" + query.trim().toLowerCase() + "%"; + params.add(like); + params.add(like); + params.add(like); + } + + if (category != null && !category.trim().isBlank()) { + sql.append(" AND LOWER(category) = LOWER(?)"); + params.add(category.trim()); + } + + String orderBy; + switch (sortBy == null ? "" : sortBy) { + case "price_asc" -> orderBy = " ORDER BY current_bid_cents ASC, end_time ASC"; + case "price_desc" -> orderBy = " ORDER BY current_bid_cents DESC, end_time ASC"; + default -> orderBy = " ORDER BY end_time DESC"; + } + sql.append(orderBy); + + try (var pstmt = connection.prepareStatement(sql.toString())) { + for (int i = 0; i < params.size(); i++) { + pstmt.setObject(i + 1, params.get(i)); + } + try (var rs = pstmt.executeQuery()) { + while (rs.next()) { + list.add(mapRowToAuction(rs)); + } + } + } catch (java.sql.SQLException e) { + throw new RuntimeException("Failed to search all auctions", e); + } + + return list; + } + public List findActiveExpiredAuctions(String nowTimeIso) { List list = new java.util.ArrayList<>(); String sql = "SELECT * FROM auction_items WHERE status = 'ACTIVE' AND end_time <= ?"; @@ -110,6 +215,34 @@ public List findActiveExpiredAuctions(String nowTimeIso) { return list; } + public List findScheduledAuctionsToStart(String nowTimeIso) { + List list = new java.util.ArrayList<>(); + String sql = "SELECT * FROM auction_items WHERE status = 'SCHEDULED' AND start_time <= ? AND COALESCE(start_mode, 'AUTO') = 'AUTO'"; + try (var pstmt = connection.prepareStatement(sql)) { + pstmt.setString(1, nowTimeIso); + try (var rs = pstmt.executeQuery()) { + while (rs.next()) list.add(mapRowToAuction(rs)); + } + } catch (java.sql.SQLException e) { + throw new RuntimeException("Failed to fetch scheduled auctions", e); + } + return list; + } + + public List findManualScheduledAuctionsOverdue(String cutoffIso) { + List list = new java.util.ArrayList<>(); + String sql = "SELECT * FROM auction_items WHERE status = 'SCHEDULED' AND start_time < ? AND COALESCE(start_mode, 'AUTO') = 'MANUAL'"; + try (var pstmt = connection.prepareStatement(sql)) { + pstmt.setString(1, cutoffIso); + try (var rs = pstmt.executeQuery()) { + while (rs.next()) list.add(mapRowToAuction(rs)); + } + } catch (java.sql.SQLException e) { + throw new RuntimeException("Failed to fetch overdue manual auctions", e); + } + return list; + } + public List findAuctionsBySeller(String sellerUsername) { List list = new java.util.ArrayList<>(); String sql = "SELECT * FROM auction_items WHERE seller_username = ?"; @@ -158,6 +291,28 @@ public void updateAuctionEndTime(int auctionId, String newEndTime) { } } + public void updateAuctionCapEndTime(int auctionId, String newCapEndTime) { + String sql = "UPDATE auction_items SET cap_end_time = ? WHERE id = ?"; + try (var pstmt = connection.prepareStatement(sql)) { + pstmt.setString(1, newCapEndTime); + pstmt.setInt(2, auctionId); + pstmt.executeUpdate(); + } catch (java.sql.SQLException e) { + throw new RuntimeException("Failed to update cap end time", e); + } + } + + public void updateAuctionStartTime(int auctionId, String newStartTime) { + String sql = "UPDATE auction_items SET start_time = ? WHERE id = ?"; + try (var pstmt = connection.prepareStatement(sql)) { + pstmt.setString(1, newStartTime); + pstmt.setInt(2, auctionId); + pstmt.executeUpdate(); + } catch (java.sql.SQLException e) { + throw new RuntimeException("Failed to update start time", e); + } + } + public void updateAuctionImages(int auctionId, String img1, String img2, String img3) { String sql = "UPDATE auction_items SET img1 = ?, img2 = ?, img3 = ? WHERE id = ?"; try (var pstmt = connection.prepareStatement(sql)) { @@ -171,7 +326,37 @@ public void updateAuctionImages(int auctionId, String img1, String img2, String } } - + public void updateAuctionDetails(AuctionItem item) { + String sql = "UPDATE auction_items SET title = ?, description = ?, category = ?, starting_price_cents = ?, current_bid_cents = ?, " + + "highest_bidder_username = ?, seller_username = ?, start_time = ?, end_time = ?, cap_end_time = ?, status = ?, start_mode = ?, min_increment_percent = ?, img1 = ?, img2 = ?, img3 = ?, relisted_from = ? WHERE id = ?"; + try (var pstmt = connection.prepareStatement(sql)) { + pstmt.setString(1, item.getTitle()); + pstmt.setString(2, item.getDescription()); + pstmt.setString(3, item.getCategory()); + pstmt.setLong(4, item.getStartingPriceCents()); + pstmt.setLong(5, item.getCurrentBidCents()); + pstmt.setString(6, item.getHighestBidderUsername()); + pstmt.setString(7, item.getSellerUsername()); + pstmt.setString(8, item.getStartTime()); + pstmt.setString(9, item.getEndTime()); + pstmt.setString(10, item.getCapEndTime()); + pstmt.setString(11, item.getStatus()); + pstmt.setString(12, item.getStartMode() == null ? com.auction.shared.Constants.START_MODE_AUTO : item.getStartMode()); + pstmt.setDouble(13, item.getMinIncrementPercent()); + pstmt.setString(14, item.getImg1()); + pstmt.setString(15, item.getImg2()); + pstmt.setString(16, item.getImg3()); + if (item.getRelistedFrom() != null) { + pstmt.setInt(17, item.getRelistedFrom()); + } else { + pstmt.setNull(17, java.sql.Types.INTEGER); + } + pstmt.setInt(18, item.getId()); + pstmt.executeUpdate(); + } catch (java.sql.SQLException e) { + throw new RuntimeException("Failed to update auction details", e); + } + } public List findWonAuctionsByBidder(String bidderUsername) { List list = new java.util.ArrayList<>(); diff --git a/src/main/java/com/auction/server/repository/DatabaseManager.java b/src/main/java/com/auction/server/repository/DatabaseManager.java index 457dbdb..dc1cc18 100644 --- a/src/main/java/com/auction/server/repository/DatabaseManager.java +++ b/src/main/java/com/auction/server/repository/DatabaseManager.java @@ -15,12 +15,22 @@ public class DatabaseManager { private Connection connection; + private final String dbUrl; + private final String configuredPath; public DatabaseManager() { this("jdbc:sqlite:" + Constants.DB_PATH); } public DatabaseManager(String dbUrl) { + this.dbUrl = dbUrl; + String sqlitePrefix = "jdbc:sqlite:"; + if (dbUrl.startsWith(sqlitePrefix)) { + this.configuredPath = dbUrl.substring(sqlitePrefix.length()); + } else { + this.configuredPath = null; + } + bootstrapDirectories(); migrateLegacyDatabaseIfNeeded(dbUrl); try { @@ -28,12 +38,139 @@ public DatabaseManager(String dbUrl) { try (var stmt = connection.createStatement()) { stmt.execute("PRAGMA foreign_keys = ON;"); } + // If an existing DB uses the old auction_items schema, apply migration + applyScheduledMigrationIfNeeded(); + applyStartModeColumnMigrationIfNeeded(); initSchema(); } catch (SQLException e) { throw new RuntimeException("Failed to initialize database", e); } } + private void applyStartModeColumnMigrationIfNeeded() { + try (var stmt = connection.createStatement()) { + boolean hasColumn = false; + try (var rs = stmt.executeQuery("PRAGMA table_info(auction_items)")) { + while (rs.next()) { + String col = rs.getString("name"); + if ("start_mode".equalsIgnoreCase(col)) { + hasColumn = true; + break; + } + } + } + if (!hasColumn) { + stmt.execute("ALTER TABLE auction_items ADD COLUMN start_mode TEXT NOT NULL DEFAULT 'AUTO'"); + System.out.println("[RTDAS] Added start_mode column to auction_items"); + } + // Ensure min_increment_percent exists (double, default to global constant) + boolean hasMinInc = false; + try (var rs2 = stmt.executeQuery("PRAGMA table_info(auction_items)")) { + while (rs2.next()) { + String col = rs2.getString("name"); + if ("min_increment_percent".equalsIgnoreCase(col)) { + hasMinInc = true; + break; + } + } + } + if (!hasMinInc) { + stmt.execute("ALTER TABLE auction_items ADD COLUMN min_increment_percent REAL NOT NULL DEFAULT " + Constants.MIN_BID_INCREMENT_PERCENT); + System.out.println("[RTDAS] Added min_increment_percent column to auction_items"); + } + } catch (Exception e) { + System.err.println("[RTDAS] start_mode migration check failed: " + e.getMessage()); + } + } + + /** + * Detects if `auction_items` table allows SCHEDULED status; if not, performs an in-place migration + * (backups the DB file and rebuilds the table with the new schema while preserving data). + */ + private void applyScheduledMigrationIfNeeded() { + if (configuredPath == null) return; + try (var stmt = connection.createStatement()) { + try (var rs = stmt.executeQuery("SELECT sql FROM sqlite_master WHERE type='table' AND name='auction_items'")) { + if (rs.next()) { + String sql = rs.getString("sql"); + if (sql != null && sql.contains("SCHEDULED")) { + // already migrated + return; + } + } else { + // No table yet, nothing to migrate + return; + } + } + + // Backup current DB file + try { + Path src = Path.of(configuredPath); + if (Files.exists(src)) { + Path backup = src.resolveSibling(src.getFileName().toString() + ".bak." + System.currentTimeMillis()); + Files.copy(src, backup); + System.out.println("[RTDAS] Backed up DB to " + backup.toString()); + } + } catch (Exception e) { + System.err.println("[RTDAS] DB backup failed: " + e.getMessage()); + } + + // Run migration statements + String[] stmts = new String[] { + "CREATE TABLE IF NOT EXISTS auction_items_new (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "title TEXT NOT NULL, " + + "description TEXT, " + + "category TEXT NOT NULL, " + + "starting_price_cents INTEGER NOT NULL CHECK(starting_price_cents >= 0), " + + "current_bid_cents INTEGER NOT NULL CHECK(current_bid_cents >= 0), " + + "highest_bidder_username TEXT, " + + "seller_username TEXT NOT NULL, " + + "start_time TEXT NOT NULL, " + + "end_time TEXT NOT NULL, " + + "cap_end_time TEXT, " + + "status TEXT NOT NULL CHECK(status IN ('SCHEDULED','ACTIVE','SOLD','EXPIRED','CANCELLED')), " + + "start_mode TEXT NOT NULL DEFAULT 'AUTO', " + + "min_increment_percent REAL NOT NULL DEFAULT " + Constants.MIN_BID_INCREMENT_PERCENT + ", " + + "img1 TEXT, img2 TEXT, img3 TEXT, " + + "relisted_from INTEGER" + + ")", + "INSERT INTO auction_items_new (id, title, description, category, starting_price_cents, current_bid_cents, " + + "highest_bidder_username, seller_username, start_time, end_time, cap_end_time, status, start_mode, min_increment_percent, img1, img2, img3, relisted_from) " + + "SELECT id, title, description, category, starting_price_cents, current_bid_cents, " + + "highest_bidder_username, seller_username, start_time, end_time, cap_end_time, status, 'AUTO', " + Constants.MIN_BID_INCREMENT_PERCENT + ", img1, img2, img3, relisted_from FROM auction_items", + "DROP TABLE auction_items", + "ALTER TABLE auction_items_new RENAME TO auction_items", + "CREATE INDEX IF NOT EXISTS idx_bids_auction_id ON bids(auction_item_id)", + "CREATE INDEX IF NOT EXISTS idx_auction_status_end ON auction_items(status, end_time)", + "CREATE INDEX IF NOT EXISTS idx_auction_seller ON auction_items(seller_username)" + }; + + try { + connection.setAutoCommit(false); + try (var s = connection.createStatement()) { + // Temporarily disable foreign key enforcement for the copy + s.execute("PRAGMA foreign_keys = OFF"); + for (String m : stmts) { + s.execute(m); + } + // Re-enable foreign keys + s.execute("PRAGMA foreign_keys = ON"); + } + connection.commit(); + System.out.println("[RTDAS] Applied scheduled-auction migration"); + } catch (Exception e) { + try { connection.rollback(); } catch (Exception ignored) {} + System.err.println("[RTDAS] Migration failed: " + e.getMessage()); + } finally { + try { connection.setAutoCommit(true); } catch (Exception ignored) {} + } + + } catch (Exception e) { + System.err.println("[RTDAS] Migration check failed: " + e.getMessage()); + } + } + private void migrateLegacyDatabaseIfNeeded(String dbUrl) { String sqlitePrefix = "jdbc:sqlite:"; if (!dbUrl.startsWith(sqlitePrefix)) { @@ -76,7 +213,7 @@ private void initSchema() throws SQLException { "created_at TEXT NOT NULL" + ")"); - stmt.execute("CREATE TABLE IF NOT EXISTS auction_items (" + + stmt.execute("CREATE TABLE IF NOT EXISTS auction_items (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "title TEXT NOT NULL, " + "description TEXT, " + @@ -87,8 +224,10 @@ private void initSchema() throws SQLException { "seller_username TEXT NOT NULL, " + "start_time TEXT NOT NULL, " + "end_time TEXT NOT NULL, " + - "cap_end_time TEXT NOT NULL, " + - "status TEXT NOT NULL CHECK(status IN ('ACTIVE','SOLD','EXPIRED','CANCELLED')), " + + "cap_end_time TEXT, " + + "status TEXT NOT NULL CHECK(status IN ('SCHEDULED','ACTIVE','SOLD','EXPIRED','CANCELLED')), " + + "start_mode TEXT NOT NULL DEFAULT 'AUTO', " + + "min_increment_percent REAL NOT NULL DEFAULT " + Constants.MIN_BID_INCREMENT_PERCENT + ", " + "img1 TEXT, img2 TEXT, img3 TEXT, " + "relisted_from INTEGER, " + "FOREIGN KEY (seller_username) REFERENCES users(username), " + diff --git a/src/main/java/com/auction/server/service/AuctionReaper.java b/src/main/java/com/auction/server/service/AuctionReaper.java index 63fa9cf..12a5673 100644 --- a/src/main/java/com/auction/server/service/AuctionReaper.java +++ b/src/main/java/com/auction/server/service/AuctionReaper.java @@ -32,8 +32,14 @@ public void start() { t.setDaemon(true); return t; }); - scheduler.scheduleAtFixedRate(lifecycleManager::sweepOverdue, 0, - Constants.REAPER_INTERVAL_SECONDS, TimeUnit.SECONDS); + scheduler.scheduleAtFixedRate(() -> { + try { + lifecycleManager.activateScheduled(); + } catch (Exception e) { + System.err.println("Error activating scheduled auctions: " + e.getMessage()); + } + lifecycleManager.sweepOverdue(); + }, 0, Constants.REAPER_INTERVAL_SECONDS, TimeUnit.SECONDS); } /** Stop the reaper. Call on server shutdown. */ diff --git a/src/main/java/com/auction/server/service/AuctionServiceImpl.java b/src/main/java/com/auction/server/service/AuctionServiceImpl.java index 1b6fe3e..e248ead 100644 --- a/src/main/java/com/auction/server/service/AuctionServiceImpl.java +++ b/src/main/java/com/auction/server/service/AuctionServiceImpl.java @@ -172,10 +172,25 @@ public List getActiveAuctions() throws RemoteException { return auctionManager.getActiveAuctions(); } + @Override + public List getAllAuctions() throws RemoteException { + return auctionManager.getAllAuctions(); + } + + @Override + public List searchActiveAuctions(String query, String category, String sortBy) throws RemoteException { + return auctionManager.searchActiveAuctions(query, category, sortBy); + } + + @Override + public List searchAllAuctions(String query, String category, String sortBy) throws RemoteException { + return auctionManager.searchAllAuctions(query, category, sortBy); + } + @Override public List getActiveAuctionsBySeller(String sellerUsername, String token) throws RemoteException, AuctionException { validateSession(token); - return auctionManager.findActiveAuctionsBySeller(sellerUsername); + return auctionManager.findAuctionsBySeller(sellerUsername); } @Override @@ -225,6 +240,26 @@ public int createAuction(AuctionItem item, byte[] image1, byte[] image2, byte[] } } + @Override + public void updateAuction(int auctionId, AuctionItem item, byte[] image1, byte[] image2, byte[] image3, String token) + throws RemoteException, AuctionException { + SessionContext context = validateRole(token, Constants.USER, Constants.ADMIN); + + String[] stagedPaths = null; + try { + stagedPaths = (image1 != null || image2 != null || image3 != null) + ? imageStore.stageImages(image1, image2, image3) + : null; + auctionManager.updateAuction(auctionId, item, context, stagedPaths); + } catch (AuctionException e) { + imageStore.deleteStagedImages(stagedPaths); + throw e; + } catch (Exception e) { + imageStore.deleteStagedImages(stagedPaths); + throw new AuctionException("Internal error updating auction: " + e.getMessage()); + } + } + @Override public void cancelAuction(int auctionId, String token) throws RemoteException, AuctionException { SessionContext context = validateRole(token, Constants.USER, Constants.ADMIN); @@ -252,6 +287,18 @@ public void relistAuction(int auctionId, String newEndTimeIso, String token) } } + @Override + public void startAuction(int auctionId, String token) throws RemoteException, AuctionException { + SessionContext context = validateRole(token, Constants.USER, Constants.ADMIN); + try { + auctionManager.startAuction(auctionId, context); + } catch (AuctionException e) { + throw e; + } catch (Exception e) { + throw new AuctionException("Internal error starting auction: " + e.getMessage()); + } + } + // --- Bidder Activity --- @Override @@ -272,7 +319,11 @@ public List getMyWonAuctions(String token) throws RemoteException, public byte[] getThumbnail(int auctionId, int imageIndex) throws RemoteException { AuctionItem item = auctionManager.getAuctionById(auctionId); if (item == null) return new byte[0]; - return imageStore.loadThumbnail(item.getImg1()); + String path = null; + if (imageIndex == 0) path = item.getImg1(); + else if (imageIndex == 1) path = item.getImg2(); + else if (imageIndex == 2) path = item.getImg3(); + return imageStore.loadThumbnail(path); } @Override @@ -280,9 +331,9 @@ public byte[] getFullImage(int auctionId, int imageIndex) throws RemoteException AuctionItem item = auctionManager.getAuctionById(auctionId); if (item == null) return new byte[0]; String path = null; - if (imageIndex == 1) path = item.getImg1(); - else if (imageIndex == 2) path = item.getImg2(); - else if (imageIndex == 3) path = item.getImg3(); + if (imageIndex == 0) path = item.getImg1(); + else if (imageIndex == 1) path = item.getImg2(); + else if (imageIndex == 2) path = item.getImg3(); return imageStore.loadFullImage(path); } diff --git a/src/main/java/com/auction/shared/Constants.java b/src/main/java/com/auction/shared/Constants.java index 778120c..b957199 100644 --- a/src/main/java/com/auction/shared/Constants.java +++ b/src/main/java/com/auction/shared/Constants.java @@ -32,7 +32,10 @@ private Constants() {} // --- Images --- public static final int MAX_IMAGES_PER_AUCTION = 3; public static final long MAX_IMAGE_SIZE_BYTES = 2 * 1024 * 1024; // 2MB + public static final int MAX_IMAGE_WIDTH = 2000; + public static final int MAX_IMAGE_HEIGHT = 2000; public static final int THUMBNAIL_SIZE = 40; + public static final java.util.Set SUPPORTED_IMAGE_FORMATS = java.util.Set.of("jpg", "jpeg", "png"); // --- Paths --- public static final String DB_PATH = "data/auction.db.sqlite"; @@ -46,10 +49,15 @@ private Constants() {} // --- Auction Status --- public static final String STATUS_ACTIVE = "ACTIVE"; + public static final String STATUS_SCHEDULED = "SCHEDULED"; public static final String STATUS_SOLD = "SOLD"; public static final String STATUS_EXPIRED = "EXPIRED"; public static final String STATUS_CANCELLED = "CANCELLED"; + // --- Start Modes --- + public static final String START_MODE_AUTO = "AUTO"; + public static final String START_MODE_MANUAL = "MANUAL"; + // --- Money Helper --- public static String formatCents(long cents) { return String.format("$%.2f", cents / 100.0); diff --git a/src/main/java/com/auction/shared/interfaces/IAuctionService.java b/src/main/java/com/auction/shared/interfaces/IAuctionService.java index 3bd768b..c3be3bc 100644 --- a/src/main/java/com/auction/shared/interfaces/IAuctionService.java +++ b/src/main/java/com/auction/shared/interfaces/IAuctionService.java @@ -27,6 +27,58 @@ public interface IAuctionService extends Remote { // --- Auction Browsing --- List getActiveAuctions() throws RemoteException; + List getAllAuctions() throws RemoteException; + /** + * Server-side active auction query. + * @param query free-text query over title/description/category (nullable) + * @param category exact category filter (nullable) + * @param sortBy one of: newest, price_asc, price_desc + */ + default List searchActiveAuctions(String query, String category, String sortBy) throws RemoteException { + // Backward-compatible default for tests/fakes that don't override this yet. + List base = getActiveAuctions(); + if (base == null) return java.util.List.of(); + + String q = query == null ? "" : query.trim().toLowerCase(); + String c = category == null ? "" : category.trim(); + + java.util.stream.Stream stream = base.stream(); + if (!q.isBlank()) { + stream = stream.filter(a -> containsIgnoreCase(a.getTitle(), q) + || containsIgnoreCase(a.getDescription(), q) + || containsIgnoreCase(a.getCategory(), q)); + } + 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(); + } + + /** + * Backward-compatible default for implementations that do not yet override all-auction search. + */ + default List searchAllAuctions(String query, String category, String sortBy) throws RemoteException { + return searchActiveAuctions(query, category, sortBy); + } + + private static boolean containsIgnoreCase(String value, String needleLower) { + return value != null && value.toLowerCase().contains(needleLower); + } + List getActiveAuctionsBySeller(String sellerUsername, String token) throws RemoteException, AuctionException; AuctionItem getAuctionById(int auctionId) throws RemoteException; @@ -40,11 +92,18 @@ void placeBid(int auctionId, long amountCents, long clientExpectedPriceCents, St /** Returns the new auction's ID. image bytes may be null if no image provided. */ int createAuction(AuctionItem item, byte[] image1, byte[] image2, byte[] image3, String token) throws RemoteException, AuctionException; + default void updateAuction(int auctionId, AuctionItem item, byte[] image1, byte[] image2, byte[] image3, String token) + throws RemoteException, AuctionException { + throw new AuctionException("Auction editing is not supported by this implementation"); + } void cancelAuction(int auctionId, String token) throws RemoteException, AuctionException; void relistAuction(int auctionId, String newEndTimeIso, String token) throws RemoteException, AuctionException; + /** Start a scheduled auction immediately (seller or admin only) */ + void startAuction(int auctionId, String token) throws RemoteException, AuctionException; + // --- Bidder Activity --- List getMyBids(String token) throws RemoteException, AuctionException; List getMyWonAuctions(String token) throws RemoteException, AuctionException; diff --git a/src/main/java/com/auction/shared/models/AuctionItem.java b/src/main/java/com/auction/shared/models/AuctionItem.java index f471824..b33e284 100644 --- a/src/main/java/com/auction/shared/models/AuctionItem.java +++ b/src/main/java/com/auction/shared/models/AuctionItem.java @@ -22,10 +22,12 @@ public class AuctionItem implements Serializable, Comparable { private String endTime; // ISO-8601 UTC private String capEndTime; // ISO-8601 UTC (snipe cap) private String status; // from AuctionStatus enum + private String startMode; // AUTO or MANUAL private String img1; // filename or null private String img2; private String img3; private Integer relistedFrom; // ID of parent auction if relisted, else null + private double minIncrementPercent; // seller-configurable minimum increment (0.05 = 5%) public AuctionItem() {} @@ -44,7 +46,9 @@ public AuctionItem(int id, String title, String description, String category, this.endTime = endTime; this.capEndTime = capEndTime; this.status = "ACTIVE"; + this.startMode = "AUTO"; this.relistedFrom = null; + this.minIncrementPercent = com.auction.shared.Constants.MIN_BID_INCREMENT_PERCENT; } @Override @@ -65,10 +69,12 @@ public int compareTo(AuctionItem other) { public String getEndTime() { return endTime; } public String getCapEndTime() { return capEndTime; } public String getStatus() { return status; } + public String getStartMode() { return startMode; } public String getImg1() { return img1; } public String getImg2() { return img2; } public String getImg3() { return img3; } public Integer getRelistedFrom() { return relistedFrom; } + public double getMinIncrementPercent() { return minIncrementPercent; } // --- Setters --- public void setId(int id) { this.id = id; } @@ -83,10 +89,12 @@ public int compareTo(AuctionItem other) { public void setEndTime(String endTime) { this.endTime = endTime; } public void setCapEndTime(String capEndTime) { this.capEndTime = capEndTime; } public void setStatus(String status) { this.status = status; } + public void setStartMode(String startMode) { this.startMode = startMode; } public void setImg1(String img1) { this.img1 = img1; } public void setImg2(String img2) { this.img2 = img2; } public void setImg3(String img3) { this.img3 = img3; } public void setRelistedFrom(Integer relistedFrom) { this.relistedFrom = relistedFrom; } + public void setMinIncrementPercent(double minIncrementPercent) { this.minIncrementPercent = minIncrementPercent; } @Override public String toString() { diff --git a/src/main/java/com/auction/shared/models/Bid.java b/src/main/java/com/auction/shared/models/Bid.java index 44c2f3c..f13785a 100644 --- a/src/main/java/com/auction/shared/models/Bid.java +++ b/src/main/java/com/auction/shared/models/Bid.java @@ -1,6 +1,12 @@ package com.auction.shared.models; +import com.auction.shared.Constants; + import java.io.Serializable; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; /** * Records a single bid on an auction item. Serializable for RMI transport. @@ -8,6 +14,8 @@ */ public class Bid implements Serializable { private static final long serialVersionUID = 1L; + private static final DateTimeFormatter TIMESTAMP_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); private int id; private int auctionItemId; @@ -30,7 +38,20 @@ public Bid(int id, int auctionItemId, String bidderUsername, long amountCents, S public int getAuctionItemId() { return auctionItemId; } public String getBidderUsername() { return bidderUsername; } public long getAmountCents() { return amountCents; } + public String getAmountFormatted() { return Constants.formatCents(amountCents); } public String getTimestamp() { return timestamp; } + public String getTimestampFormatted() { + if (timestamp == null || timestamp.isBlank()) { + return ""; + } + try { + Instant instant = Instant.parse(timestamp); + ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault()); + return zonedDateTime.format(TIMESTAMP_FORMATTER); + } catch (Exception ignored) { + return timestamp; + } + } // --- Setters --- public void setId(int id) { this.id = id; } diff --git a/src/main/resources/css/style.css b/src/main/resources/css/style.css index 089b089..f00679a 100644 --- a/src/main/resources/css/style.css +++ b/src/main/resources/css/style.css @@ -197,6 +197,48 @@ -fx-font-family: "Inter"; } +/* Inline next-min label shown beside bid input */ +.inline-next-min { + -fx-font-size: 12px; + -fx-text-fill: #8b949e; + -fx-font-family: "Inter"; + -fx-alignment: CENTER_LEFT; + -fx-padding: 0 0 0 8px; /* space from input */ + -fx-min-height: 44px; /* match input height */ +} + +/* Reconnect banner shown on polling failures */ +.reconnect-banner { + -fx-background-color: linear-gradient(to right, rgba(210,153,34,0.12), rgba(210,153,34,0.18)); + -fx-border-color: rgba(210,153,34,0.28); + -fx-border-width: 1px; + -fx-background-radius: 8px; + -fx-padding: 8px 12px; +} + +/* Countdown states for AuctionDetail */ +.countdown-normal { + -fx-text-fill: #7ee787; + -fx-font-weight: 700; +} +.countdown-warning { + -fx-text-fill: #e3b341; + -fx-font-weight: 700; +} +.countdown-urgent { + -fx-text-fill: #ff6b6b; + -fx-font-weight: 800; +} +.countdown-ended { + -fx-text-fill: #8b949e; + -fx-font-weight: 600; +} + +.reconnect-banner .label { + -fx-text-fill: #ffdca8; + -fx-font-weight: 700; +} + .compact-field, .search-field, .input-field, @@ -398,6 +440,15 @@ -fx-padding: 18px; } +.hero-card, +.panel-card { + -fx-min-width: 0; +} + +.compact-field { + -fx-max-width: Infinity; +} + /* Metric Card Variants */ .metric-card-accent { -fx-border-color: rgba(88, 166, 255, 0.42); diff --git a/src/main/resources/fxml/auction_bid_history.fxml b/src/main/resources/fxml/auction_bid_history.fxml new file mode 100644 index 0000000..90ff658 --- /dev/null +++ b/src/main/resources/fxml/auction_bid_history.fxml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + +