Changeset 3465730
- Timestamp:
- 02/20/2026 11:13:52 AM (5 weeks ago)
- Location:
- photo-competition-manager/tags/0.3.0
- Files:
-
- 1 added
- 17 edited
- 13 copied
-
. (copied) (copied from photo-competition-manager/trunk)
-
admin/class-admin-dependencies.php (added)
-
admin/class-admin-screen.php (copied) (copied from photo-competition-manager/trunk/admin/class-admin-screen.php)
-
admin/class-competitions-controller.php (modified) (13 diffs)
-
admin/class-email-templates-controller.php (modified) (1 diff)
-
admin/class-members-controller.php (copied) (copied from photo-competition-manager/trunk/admin/class-members-controller.php) (4 diffs)
-
admin/class-results-controller.php (modified) (2 diffs)
-
admin/class-settings-controller.php (modified) (5 diffs)
-
admin/class-voting-controller.php (copied) (copied from photo-competition-manager/trunk/admin/class-voting-controller.php) (4 diffs)
-
assets/css/admin-slideshow.css (modified) (7 diffs)
-
assets/css/slideshow.css (modified) (4 diffs)
-
assets/js/admin-slideshow.js (modified) (9 diffs)
-
assets/js/slideshow.js (modified) (9 diffs)
-
includes/Install/class-activator.php (modified) (2 diffs)
-
includes/Repository/class-competitions-repository.php (copied) (copied from photo-competition-manager/trunk/includes/Repository/class-competitions-repository.php) (3 diffs)
-
includes/Repository/class-images-repository.php (copied) (copied from photo-competition-manager/trunk/includes/Repository/class-images-repository.php)
-
includes/Repository/class-logs-repository.php (copied) (copied from photo-competition-manager/trunk/includes/Repository/class-logs-repository.php)
-
includes/Repository/class-members-repository.php (copied) (copied from photo-competition-manager/trunk/includes/Repository/class-members-repository.php) (5 diffs)
-
includes/Repository/class-votes-repository.php (copied) (copied from photo-competition-manager/trunk/includes/Repository/class-votes-repository.php)
-
includes/Service/class-email-service.php (copied) (copied from photo-competition-manager/trunk/includes/Service/class-email-service.php) (4 diffs)
-
includes/Service/class-member-csv-importer.php (modified) (5 diffs)
-
includes/Service/class-upload-handler.php (modified) (1 diff)
-
includes/Support/class-competition-settings.php (modified) (3 diffs)
-
includes/Support/class-email-configuration.php (copied) (copied from photo-competition-manager/trunk/includes/Support/class-email-configuration.php)
-
includes/bootstrap.php (modified) (1 diff)
-
photo-competition-manager.php (copied) (copied from photo-competition-manager/trunk/photo-competition-manager.php) (1 diff)
-
public/class-results-shortcode.php (modified) (4 diffs)
-
public/class-slideshow-shortcode.php (modified) (3 diffs)
-
public/class-top3-shortcode.php (modified) (3 diffs)
-
public/class-voting-shortcode.php (modified) (1 diff)
-
readme.txt (copied) (copied from photo-competition-manager/trunk/readme.txt) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
photo-competition-manager/tags/0.3.0/admin/class-competitions-controller.php
r3415757 r3465730 120 120 document.addEventListener(\'click\', function(e) { 121 121 if (e.target.classList.contains(\'photo-comp-delete\') || 122 e.target.classList.contains(\'photo-comp-reset-votes\')) { 122 e.target.classList.contains(\'photo-comp-reset-votes\') || 123 e.target.classList.contains(\'photo-comp-regenerate-hash\')) { 123 124 var confirmMessage = e.target.getAttribute(\'data-confirm\'); 124 125 if (confirmMessage && !confirm(confirmMessage)) { … … 128 129 } 129 130 }); 131 132 // Progress meter preview animations 133 document.querySelectorAll(".progress-meter-card").forEach(function(card) { 134 card.addEventListener("click", function() { 135 document.querySelectorAll(".progress-meter-card").forEach(function(c) { 136 c.classList.remove("active"); 137 c.style.borderColor = "#ddd"; 138 }); 139 card.classList.add("active"); 140 card.style.borderColor = "#0073aa"; 141 }); 142 }); 143 144 function animatePreviews() { 145 var duration = 3000; 146 var startTime = Date.now(); 147 148 function tick() { 149 var elapsed = Date.now() - startTime; 150 var progress = (elapsed % duration) / duration; 151 152 document.querySelectorAll(".meter-preview").forEach(function(preview) { 153 var type = preview.dataset.meterType; 154 renderMeterPreview(preview, type, progress); 155 }); 156 157 requestAnimationFrame(tick); 158 } 159 160 tick(); 161 } 162 163 function renderMeterPreview(container, type, progress) { 164 if (!container._initialized) { 165 container._initialized = true; 166 container.innerHTML = ""; 167 168 if (type === "bar") { 169 container.style.display = "flex"; 170 container.style.alignItems = "flex-end"; 171 var track = document.createElement("div"); 172 track.style.cssText = "width:100%;height:8px;background:rgba(255,255,255,0.2);border-radius:0;"; 173 var fill = document.createElement("div"); 174 fill.style.cssText = "height:100%;background:#0073aa;transition:width 100ms linear;border-radius:0;"; 175 fill.className = "meter-fill"; 176 track.appendChild(fill); 177 container.appendChild(track); 178 } else if (type === "line") { 179 container.style.display = "flex"; 180 container.style.alignItems = "flex-end"; 181 var track = document.createElement("div"); 182 track.style.cssText = "width:100%;height:3px;background:rgba(255,255,255,0.1);"; 183 var fill = document.createElement("div"); 184 fill.style.cssText = "height:100%;background:#fff;box-shadow:0 0 8px rgba(255,255,255,0.6);transition:width 100ms linear;"; 185 fill.className = "meter-fill"; 186 track.appendChild(fill); 187 container.appendChild(track); 188 } else if (type === "dots") { 189 container.style.display = "flex"; 190 container.style.alignItems = "flex-end"; 191 container.style.justifyContent = "center"; 192 container.style.gap = "4px"; 193 container.style.paddingBottom = "4px"; 194 for (var i = 0; i < 15; i++) { 195 var dot = document.createElement("div"); 196 dot.style.cssText = "width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,0.2);transition:background 0.2s,transform 0.2s;"; 197 dot.className = "meter-dot"; 198 container.appendChild(dot); 199 } 200 } else if (type === "radial") { 201 container.style.display = "flex"; 202 container.style.alignItems = "center"; 203 container.style.justifyContent = "center"; 204 var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 205 svg.setAttribute("width", "40"); 206 svg.setAttribute("height", "40"); 207 svg.setAttribute("viewBox", "0 0 40 40"); 208 var bgCircle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); 209 bgCircle.setAttribute("cx", "20"); 210 bgCircle.setAttribute("cy", "20"); 211 bgCircle.setAttribute("r", "16"); 212 bgCircle.setAttribute("fill", "none"); 213 bgCircle.setAttribute("stroke", "rgba(255,255,255,0.2)"); 214 bgCircle.setAttribute("stroke-width", "3"); 215 svg.appendChild(bgCircle); 216 var circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); 217 circle.setAttribute("cx", "20"); 218 circle.setAttribute("cy", "20"); 219 circle.setAttribute("r", "16"); 220 circle.setAttribute("fill", "none"); 221 circle.setAttribute("stroke", "#0073aa"); 222 circle.setAttribute("stroke-width", "3"); 223 circle.setAttribute("stroke-linecap", "round"); 224 circle.setAttribute("transform", "rotate(-90 20 20)"); 225 var circumference = 2 * Math.PI * 16; 226 circle.setAttribute("stroke-dasharray", circumference); 227 circle.setAttribute("stroke-dashoffset", circumference); 228 circle.className.baseVal = "meter-ring"; 229 svg.appendChild(circle); 230 container.appendChild(svg); 231 } 232 } 233 234 if (type === "bar" || type === "line") { 235 var fill = container.querySelector(".meter-fill"); 236 if (fill) fill.style.width = (progress * 100) + "%"; 237 } else if (type === "dots") { 238 var dots = container.querySelectorAll(".meter-dot"); 239 var filledCount = Math.floor(progress * dots.length); 240 dots.forEach(function(dot, i) { 241 if (i < filledCount) { 242 dot.style.background = "#0073aa"; 243 dot.style.transform = "scale(1.3)"; 244 } else if (i === filledCount) { 245 dot.style.background = "rgba(0,115,170,0.5)"; 246 dot.style.transform = "scale(1.1)"; 247 } else { 248 dot.style.background = "rgba(255,255,255,0.2)"; 249 dot.style.transform = "scale(1)"; 250 } 251 }); 252 } else if (type === "radial") { 253 var ring = container.querySelector(".meter-ring"); 254 if (ring) { 255 var circumference = 2 * Math.PI * 16; 256 ring.setAttribute("stroke-dashoffset", circumference * (1 - progress)); 257 } 258 } 259 } 260 261 animatePreviews(); 130 262 })(); 131 263 }); … … 178 310 'close_date' => $this->parse_date_input( $close_date_raw ), 179 311 'settings' => $this->get_global_settings(), 312 'share_hash' => Competition_Settings::generate_share_hash(), 180 313 ); 181 314 … … 251 384 252 385 $this->redirect_with_settings_errors( $this->dashboard_url() ); 386 } 387 388 if ( 'generate_results_link' === $action && isset( $_GET['competition'] ) ) { 389 $competition_id = absint( wp_unslash( $_GET['competition'] ) ); 390 391 check_admin_referer( 'photo_competition_generate_results_link_' . $competition_id ); 392 393 $competition = $this->competitions->find( $competition_id ); 394 if ( ! $competition ) { 395 add_settings_error( 396 'photo_competition_manager', 397 'competition_not_found', 398 __( 'Competition not found.', 'photo-competition-manager' ), 399 'error' 400 ); 401 $this->redirect_with_settings_errors( $this->dashboard_url() ); 402 return; 403 } 404 405 $new_hash = Competition_Settings::generate_share_hash(); 406 $result = $this->competitions->update_share_hash( $competition_id, $new_hash ); 407 408 if ( is_wp_error( $result ) ) { 409 add_settings_error( 410 'photo_competition_manager', 411 $result->get_error_code(), 412 $result->get_error_message(), 413 'error' 414 ); 415 } else { 416 $settings = Competition_Settings::parse( $competition->settings ); 417 $results_page_url = $settings['urls']['results_page'] ?? ''; 418 if ( ! empty( $results_page_url ) ) { 419 $share_url = add_query_arg( 'share', $new_hash, $results_page_url ); 420 add_settings_error( 421 'photo_competition_manager', 422 'results_link_generated', 423 sprintf( 424 /* translators: %s: share URL */ 425 __( 'Results share link generated: <a href="%s" target="_blank">%s</a>', 'photo-competition-manager' ), 426 esc_url( $share_url ), 427 esc_html( $share_url ) 428 ), 429 'updated' 430 ); 431 } else { 432 add_settings_error( 433 'photo_competition_manager', 434 'results_link_generated', 435 __( 'Results share hash generated. Configure a results page URL to get a shareable link.', 'photo-competition-manager' ), 436 'updated' 437 ); 438 } 439 } 440 441 $this->redirect_with_settings_errors( $this->dashboard_url() ); 442 return; 253 443 } 254 444 … … 484 674 $voting_page_url = sanitize_url( $this->get_post_string( 'voting_page_url', '' ) ); 485 675 486 // Hash the password if provided, clear if checkbox checked, otherwise preserve existing hash. 487 $hashed_password = ''; 676 $progress_meter_type_input = sanitize_text_field( $this->get_post_string( 'progress_meter_type', 'bar' ) ); 677 if ( ! in_array( $progress_meter_type_input, array( 'bar', 'line', 'dots', 'radial' ), true ) ) { 678 $progress_meter_type_input = 'bar'; 679 } 680 681 // Store the password if provided, clear if empty or checkbox checked. 682 // For legacy hashed passwords (not shown in the form), preserve when field is blank. 683 $existing_password = $existing_settings['voting']['password'] ?? ''; 684 $is_legacy_hash = '' !== $existing_password && (bool) preg_match( '/^\$P\$|\$wp\$/', $existing_password ); 685 $hashed_password = ''; 488 686 if ( $voting_password_clear ) { 489 // Clear the password - leave empty.687 // Clear the password via checkbox (legacy hash flow). 490 688 $hashed_password = ''; 491 689 } elseif ( ! empty( $voting_password ) ) { 492 // New password provided - lowercase and hash it for case-insensitive comparison. 493 $hashed_password = wp_hash_password( strtolower( $voting_password ) ); 494 } elseif ( isset( $existing_settings['voting']['password'] ) ) { 495 // Preserve existing password hash if no new password provided and not clearing. 496 $hashed_password = $existing_settings['voting']['password']; 497 } 690 // Password provided - store lowercase for case-insensitive comparison. 691 $hashed_password = strtolower( $voting_password ); 692 } elseif ( $is_legacy_hash ) { 693 // Legacy hash: blank field means keep existing password. 694 $hashed_password = $existing_password; 695 } 696 697 // Preserve existing results settings (results_visible) controlled via Voting Controls page. 698 $existing_results = $existing_settings['results'] ?? array(); 498 699 499 700 $settings = array( … … 515 716 ), 516 717 'slideshow' => array( 517 'duration_seconds' => 10, 718 'duration_seconds' => 10, 719 'progress_meter_type' => $progress_meter_type_input, 518 720 ), 519 721 'email_reminders' => array( … … 527 729 'voting_page' => $voting_page_url, 528 730 ), 731 'results' => $existing_results, 529 732 ); 530 733 … … 769 972 */ 770 973 private function render_competition_settings_form( object $competition ): void { 771 $settings = Competition_Settings::parse( $competition->settings ); 772 $categories = Competition_Settings::get_categories( $settings ); 773 $grades = Competition_Settings::get_grades( $settings ); 774 $upload = Competition_Settings::get_upload_constraints( $settings ); 775 $voting = Competition_Settings::get_voting_config( $settings ); 776 $voting_ui_type = $voting['ui_type'] ?? ''; 974 $settings = Competition_Settings::parse( $competition->settings ); 975 $categories = Competition_Settings::get_categories( $settings ); 976 $grades = Competition_Settings::get_grades( $settings ); 977 $upload = Competition_Settings::get_upload_constraints( $settings ); 978 $voting = Competition_Settings::get_voting_config( $settings ); 979 $slideshow = $settings['slideshow'] ?? array(); 980 $progress_meter_type = $slideshow['progress_meter_type'] ?? 'bar'; 981 $voting_ui_type = $voting['ui_type'] ?? ''; 777 982 if ( ! in_array( $voting_ui_type, array( 'buttons', 'dropdown' ), true ) ) { 778 983 $voting_ui_type = Competition_Settings::get_voting_ui_type( $settings ); … … 843 1048 echo '<label for="voting_password">' . esc_html__( 'Voting Password (for password mode)', 'photo-competition-manager' ) . '</label><br />'; 844 1049 845 // Show placeholder if password is set, empty if not. 846 $password_placeholder = ! empty( $voting['password'] ) ? __( 'Password is set', 'photo-competition-manager' ) : ''; 847 echo '<input type="text" id="voting_password" name="voting_password" value="" placeholder="' . esc_attr( $password_placeholder ) . '" class="regular-text" />'; 848 849 if ( ! empty( $voting['password'] ) ) { 1050 $is_plaintext_password = ! empty( $voting['password'] ) && ! preg_match( '/^\$P\$|\$wp\$/', $voting['password'] ); 1051 $is_legacy_hash = ! empty( $voting['password'] ) && ! $is_plaintext_password; 1052 $password_value = $is_plaintext_password ? $voting['password'] : ''; 1053 1054 echo '<input type="text" id="voting_password" name="voting_password" value="' . esc_attr( $password_value ) . '" class="regular-text" />'; 1055 1056 if ( $is_legacy_hash ) { 850 1057 echo '<br /><label>'; 851 1058 echo '<input type="checkbox" id="voting_password_clear" name="voting_password_clear" value="1" />'; … … 854 1061 echo '<br /><span class="description">' . esc_html__( 'A password is currently set. Enter a new password to change it, check the box above to remove password protection, or leave both blank to keep the existing password. Passwords are not case-sensitive.', 'photo-competition-manager' ) . '</span>'; 855 1062 } else { 856 echo '<br /><span class="description">' . esc_html__( ' Voters must enter this password before submitting votes. Leave blank to disable. Only used when auth mode is "Password-based". Passwords are not case-sensitive.', 'photo-competition-manager' ) . '</span>';1063 echo '<br /><span class="description">' . esc_html__( 'Leave blank for no password. Passwords are case insensitive.', 'photo-competition-manager' ) . '</span>'; 857 1064 } 858 1065 echo '</p>'; … … 882 1089 echo '</p>'; 883 1090 1091 echo '<h3>' . esc_html__( 'Slideshow', 'photo-competition-manager' ) . '</h3>'; 1092 1093 echo '<p>'; 1094 echo '<label>' . esc_html__( 'Progress Meter Style', 'photo-competition-manager' ) . '</label>'; 1095 echo '</p>'; 1096 1097 $meter_types = array( 1098 'bar' => __( 'Bar', 'photo-competition-manager' ), 1099 'line' => __( 'Thin Line', 'photo-competition-manager' ), 1100 'dots' => __( 'Dots', 'photo-competition-manager' ), 1101 'radial' => __( 'Radial', 'photo-competition-manager' ), 1102 ); 1103 1104 echo '<div class="progress-meter-selector" style="display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 20px;">'; 1105 1106 foreach ( $meter_types as $type => $label ) { 1107 $is_active = ( $type === $progress_meter_type ) ? ' active' : ''; 1108 echo '<label class="progress-meter-card' . esc_attr( $is_active ) . '" style="cursor: pointer; border: 2px solid ' . ( $is_active ? '#0073aa' : '#ddd' ) . '; border-radius: 8px; padding: 12px; text-align: center; background: #1a1a1a; min-width: 140px; transition: border-color 0.2s;">'; 1109 echo '<input type="radio" name="progress_meter_type" value="' . esc_attr( $type ) . '"' . checked( $progress_meter_type, $type, false ) . ' style="display: none;" />'; 1110 echo '<div class="meter-preview" data-meter-type="' . esc_attr( $type ) . '" style="height: 50px; position: relative; margin-bottom: 8px; overflow: hidden; border-radius: 4px;"></div>'; 1111 echo '<span style="color: #666; font-size: 13px; font-weight: 600;">' . esc_html( $label ) . '</span>'; 1112 echo '</label>'; 1113 } 1114 1115 echo '</div>'; 1116 echo '<span class="description">' . esc_html__( 'Choose the progress indicator style shown during the slideshow.', 'photo-competition-manager' ) . '</span>'; 1117 884 1118 echo '<h3>' . esc_html__( 'URLs', 'photo-competition-manager' ) . '</h3>'; 885 1119 … … 900 1134 echo '<br /><span class="description">' . esc_html__( 'The page where members can vote on images. This URL will be included in voting notification emails.', 'photo-competition-manager' ) . '</span>'; 901 1135 echo '</p>'; 1136 1137 $share_hash = $competition->share_hash ?? ''; 1138 if ( ! empty( $share_hash ) ) { 1139 echo '<p>'; 1140 echo '<label>' . esc_html__( 'Results Share Hash', 'photo-competition-manager' ) . '</label><br />'; 1141 echo '<code>' . esc_html( $share_hash ) . '</code>'; 1142 1143 $results_page_url = $urls['results_page'] ?? ''; 1144 if ( ! empty( $results_page_url ) ) { 1145 $share_url = add_query_arg( 'share', $share_hash, $results_page_url ); 1146 echo '<br /><span class="description">' . esc_html__( 'Share link:', 'photo-competition-manager' ) . ' <a href="' . esc_url( $share_url ) . '" target="_blank">' . esc_html( $share_url ) . '</a></span>'; 1147 } 1148 echo '</p>'; 1149 } 902 1150 903 1151 submit_button( __( 'Save Settings', 'photo-competition-manager' ) ); … … 1055 1303 $actions[] = sprintf( '<span title="Send only on open competitions" style="color: #888;">%s</span>', esc_html__( 'Send Upload Emails', 'photo-competition-manager' ) ); 1056 1304 } 1305 1306 // Generate Results Link action. 1307 $generate_link_url = wp_nonce_url( 1308 add_query_arg( 1309 array( 1310 'page' => 'photo-competition-manager', 1311 'action' => 'generate_results_link', 1312 'competition' => (int) $competition->id, 1313 ), 1314 admin_url( 'admin.php' ) 1315 ), 1316 'photo_competition_generate_results_link_' . (int) $competition->id 1317 ); 1318 1319 $actions[] = sprintf( 1320 '<a href="%s" class="photo-comp-regenerate-hash" data-confirm="%s">%s</a>', 1321 esc_url( $generate_link_url ), 1322 esc_attr( __( 'This will generate a new results link and invalidate any previously shared link. Continue?', 'photo-competition-manager' ) ), 1323 esc_html__( 'Generate Results Link', 'photo-competition-manager' ) 1324 ); 1057 1325 1058 1326 if ( $is_archived ) { -
photo-competition-manager/tags/0.3.0/admin/class-email-templates-controller.php
r3415757 r3465730 247 247 'enabled' => false, 248 248 'subject' => __( 'Image uploaded successfully for {competition_title}', 'photo-competition-manager' ), 249 'body' => __( "<p>Hi {member_name},</p>\n\n<p>Your image has been successfully uploaded for {competition_title} in the {category_name} category.</p>\n\n<p>You have uploaded {current_count} of {quota} images for this category.</p>\n\n<p>Thank you for your submission!</p>", 'photo-competition-manager' ),250 'merge_tags' => array( '{member_name}', '{ competition_title}', '{category_name}', '{current_count}', '{quota}', '{site_name}' ),249 'body' => __( "<p>Hi {member_name},</p>\n\n<p>Your image has been successfully uploaded for {competition_title} in the {category_name} category.</p>\n\n<p>You are entering in the {member_grade} grade.</p>\n\n<p>You have uploaded {current_count} of {quota} images for this category.</p>\n\n<p>Thank you for your submission!</p>", 'photo-competition-manager' ), 250 'merge_tags' => array( '{member_name}', '{member_grade}', '{competition_title}', '{category_name}', '{current_count}', '{quota}', '{site_name}' ), 251 251 ), 252 252 ); -
photo-competition-manager/tags/0.3.0/admin/class-members-controller.php
r3446169 r3465730 278 278 $email_raw = $this->get_post_string( 'member_email' ); 279 279 $grade_raw = $this->get_post_string( 'member_grade' ); 280 $is_active = isset( $_POST['member_active'] ); 281 $name = sanitize_text_field( $name_raw ); 282 $email = sanitize_email( $email_raw ); 283 $grade = sanitize_text_field( $grade_raw ); 280 $is_active = isset( $_POST['member_active'] ); 281 $is_committee = isset( $_POST['member_committee'] ); 282 $name = sanitize_text_field( $name_raw ); 283 $email = sanitize_email( $email_raw ); 284 $grade = sanitize_text_field( $grade_raw ); 284 285 285 286 $data = array( 286 'name' => $name, 287 'email' => $email, 288 'grade' => $grade, 289 'active' => $is_active ? 1 : 0, 287 'name' => $name, 288 'email' => $email, 289 'grade' => $grade, 290 'active' => $is_active ? 1 : 0, 291 'committee' => $is_committee ? 1 : 0, 290 292 ); 291 293 … … 316 318 check_admin_referer( 'photo_competition_member_update_' . $member_id, 'photo_competition_member_nonce' ); 317 319 318 $name_raw = $this->get_post_string( 'member_name' ); 319 $email_raw = $this->get_post_string( 'member_email' ); 320 $grade_raw = $this->get_post_string( 'member_grade' ); 321 $is_active = isset( $_POST['member_active'] ); 322 $name = sanitize_text_field( $name_raw ); 323 $email = sanitize_email( $email_raw ); 324 $grade = sanitize_text_field( $grade_raw ); 320 $name_raw = $this->get_post_string( 'member_name' ); 321 $email_raw = $this->get_post_string( 'member_email' ); 322 $grade_raw = $this->get_post_string( 'member_grade' ); 323 $is_active = isset( $_POST['member_active'] ); 324 $is_committee = isset( $_POST['member_committee'] ); 325 $name = sanitize_text_field( $name_raw ); 326 $email = sanitize_email( $email_raw ); 327 $grade = sanitize_text_field( $grade_raw ); 325 328 326 329 $data = array( 327 'name' => $name, 328 'email' => $email, 329 'grade' => $grade, 330 'active' => $is_active ? 1 : 0, 330 'name' => $name, 331 'email' => $email, 332 'grade' => $grade, 333 'active' => $is_active ? 1 : 0, 334 'committee' => $is_committee ? 1 : 0, 331 335 ); 332 336 … … 956 960 echo '</p>'; 957 961 962 echo '<p>'; 963 echo '<label>'; 964 echo '<input type="checkbox" name="member_committee" value="1" /> '; 965 echo esc_html__( 'Committee Member', 'photo-competition-manager' ); 966 echo '</label>'; 967 echo '</p>'; 968 958 969 submit_button( __( 'Add Member', 'photo-competition-manager' ) ); 959 970 … … 1009 1020 echo '<input type="checkbox" name="member_active" value="1"' . checked( (bool) $member->active, true, false ) . ' /> '; 1010 1021 echo esc_html__( 'Active', 'photo-competition-manager' ); 1022 echo '</label>'; 1023 echo '</p>'; 1024 1025 echo '<p>'; 1026 echo '<label>'; 1027 echo '<input type="checkbox" name="member_committee" value="1"' . checked( (bool) ( $member->committee ?? false ), true, false ) . ' /> '; 1028 echo esc_html__( 'Committee Member', 'photo-competition-manager' ); 1011 1029 echo '</label>'; 1012 1030 echo '</p>'; -
photo-competition-manager/tags/0.3.0/admin/class-results-controller.php
r3415757 r3465730 260 260 } 261 261 262 if ( 'send_results_committee' === $action || 'send_results_all' === $action ) { 263 $competition_id = isset( $_GET['competition'] ) ? absint( wp_unslash( $_GET['competition'] ) ) : 0; 264 265 check_admin_referer( 'photo_competition_' . $action . '_' . $competition_id ); 266 267 $redirect_url = add_query_arg( 268 array( 269 'page' => 'photo-competition-manager-results', 270 'competition' => $competition_id, 271 ), 272 admin_url( 'admin.php' ) 273 ); 274 275 $competition = $this->competitions->find( $competition_id ); 276 if ( ! $competition ) { 277 add_settings_error( 278 'photo_competition_results', 279 'competition_not_found', 280 __( 'Competition not found.', 'photo-competition-manager' ), 281 'error' 282 ); 283 $this->redirect_with_settings_errors( $redirect_url ); 284 return; 285 } 286 287 $share_hash = $competition->share_hash ?? ''; 288 289 if ( empty( $share_hash ) ) { 290 add_settings_error( 291 'photo_competition_results', 292 'no_share_hash', 293 __( 'No share hash exists. Generate a results link first from the Competitions page.', 'photo-competition-manager' ), 294 'error' 295 ); 296 $this->redirect_with_settings_errors( $redirect_url ); 297 return; 298 } 299 300 $settings = Competition_Settings::parse( $competition->settings ); 301 $results_page_url = $settings['urls']['results_page'] ?? ''; 302 if ( empty( $results_page_url ) ) { 303 add_settings_error( 304 'photo_competition_results', 305 'no_results_page', 306 __( 'No results page URL configured. Set one in competition settings.', 'photo-competition-manager' ), 307 'error' 308 ); 309 $this->redirect_with_settings_errors( $redirect_url ); 310 return; 311 } 312 313 $share_url = add_query_arg( 'share', $share_hash, $results_page_url ); 314 315 if ( 'send_results_committee' === $action ) { 316 $recipients = $this->members->find_committee_members(); 317 } else { 318 $recipients = $this->members->find_active_members(); 319 } 320 321 $sent_count = 0; 322 323 foreach ( $recipients as $member ) { 324 if ( ! empty( $member->email ) ) { 325 $sent = $this->email_service->send_results_share_link( 326 $member->email, 327 $member->name, 328 $competition->title, 329 $share_url, 330 $competition_id 331 ); 332 if ( $sent ) { 333 ++$sent_count; 334 } 335 } 336 } 337 338 $audience_label = 'send_results_committee' === $action 339 ? __( 'committee members', 'photo-competition-manager' ) 340 : __( 'active members', 'photo-competition-manager' ); 341 342 add_settings_error( 343 'photo_competition_results', 344 'results_link_sent', 345 sprintf( 346 /* translators: 1: number of emails sent, 2: audience label */ 347 __( 'Results link sent to %1$d %2$s.', 'photo-competition-manager' ), 348 $sent_count, 349 $audience_label 350 ), 351 'updated' 352 ); 353 354 $this->redirect_with_settings_errors( $redirect_url ); 355 return; 356 } 357 262 358 if ( 'export_results_csv' === $action ) { 263 359 $competition_id = isset( $_GET['competition'] ) ? absint( wp_unslash( $_GET['competition'] ) ) : 0; … … 477 573 echo esc_html__( 'Email Results', 'photo-competition-manager' ); 478 574 echo '</a>'; 575 576 echo '</div>'; 577 578 // Share results section. 579 $share_hash = $competition->share_hash ?? ''; 580 $results_page = $settings['urls']['results_page'] ?? ''; 581 582 echo '<div class="photo-comp-share-results" style="margin: 20px 0; padding: 15px; background: #f9f9f9; border-left: 4px solid #2271b1;">'; 583 echo '<h3 style="margin-top: 0;">' . esc_html__( 'Share Results', 'photo-competition-manager' ) . '</h3>'; 584 585 if ( ! empty( $share_hash ) && ! empty( $results_page ) ) { 586 $share_url = add_query_arg( 'share', $share_hash, $results_page ); 587 588 $send_committee_url = wp_nonce_url( 589 add_query_arg( 590 array( 591 'page' => 'photo-competition-manager-results', 592 'action' => 'send_results_committee', 593 'competition' => (int) $competition->id, 594 ), 595 admin_url( 'admin.php' ) 596 ), 597 'photo_competition_send_results_committee_' . (int) $competition->id 598 ); 599 600 $send_all_url = wp_nonce_url( 601 add_query_arg( 602 array( 603 'page' => 'photo-competition-manager-results', 604 'action' => 'send_results_all', 605 'competition' => (int) $competition->id, 606 ), 607 admin_url( 'admin.php' ) 608 ), 609 'photo_competition_send_results_all_' . (int) $competition->id 610 ); 611 612 echo '<p class="description">'; 613 echo esc_html__( 'Share link (bypasses visibility setting):', 'photo-competition-manager' ); 614 echo '<br><code>' . esc_html( $share_url ) . '</code>'; 615 echo '</p>'; 616 echo '<p>'; 617 echo '<a href="' . esc_url( $send_committee_url ) . '" class="button" onclick="return confirm(\'' . esc_js( __( 'This will send the results link to all committee members. Continue?', 'photo-competition-manager' ) ) . '\');">'; 618 echo '<span class="dashicons dashicons-groups" style="margin-top: 4px;"></span> '; 619 echo esc_html__( 'Send to Committee', 'photo-competition-manager' ); 620 echo '</a> '; 621 echo '<a href="' . esc_url( $send_all_url ) . '" class="button" onclick="return confirm(\'' . esc_js( __( 'This will send the results link to ALL active members. Continue?', 'photo-competition-manager' ) ) . '\');">'; 622 echo '<span class="dashicons dashicons-email" style="margin-top: 4px;"></span> '; 623 echo esc_html__( 'Send to All Members', 'photo-competition-manager' ); 624 echo '</a>'; 625 echo '</p>'; 626 } elseif ( empty( $share_hash ) ) { 627 echo '<p class="description">'; 628 printf( 629 /* translators: %s: link to competitions page */ 630 esc_html__( 'No share hash exists. %s on the Competitions page first.', 'photo-competition-manager' ), 631 '<a href="' . esc_url( admin_url( 'admin.php?page=photo-competition-manager' ) ) . '">' . esc_html__( 'Generate a results link', 'photo-competition-manager' ) . '</a>' 632 ); 633 echo '</p>'; 634 } else { 635 echo '<p class="description">'; 636 echo esc_html__( 'No results page URL configured. Set one in competition settings.', 'photo-competition-manager' ); 637 echo '</p>'; 638 } 479 639 480 640 echo '</div>'; -
photo-competition-manager/tags/0.3.0/admin/class-settings-controller.php
r3415757 r3465730 125 125 } 126 126 }); 127 128 // Progress meter preview animations 129 document.querySelectorAll(".progress-meter-card").forEach(function(card) { 130 card.addEventListener("click", function() { 131 document.querySelectorAll(".progress-meter-card").forEach(function(c) { 132 c.classList.remove("active"); 133 c.style.borderColor = "#ddd"; 134 }); 135 card.classList.add("active"); 136 card.style.borderColor = "#0073aa"; 137 }); 138 }); 139 140 function animatePreviews() { 141 var duration = 3000; 142 var startTime = Date.now(); 143 144 function tick() { 145 var elapsed = Date.now() - startTime; 146 var progress = (elapsed % duration) / duration; 147 148 document.querySelectorAll(".meter-preview").forEach(function(preview) { 149 var type = preview.dataset.meterType; 150 renderMeterPreview(preview, type, progress); 151 }); 152 153 requestAnimationFrame(tick); 154 } 155 156 tick(); 157 } 158 159 function renderMeterPreview(container, type, progress) { 160 if (!container._initialized) { 161 container._initialized = true; 162 container.innerHTML = ""; 163 164 if (type === "bar") { 165 container.style.display = "flex"; 166 container.style.alignItems = "flex-end"; 167 var track = document.createElement("div"); 168 track.style.cssText = "width:100%;height:8px;background:rgba(255,255,255,0.2);border-radius:0;"; 169 var fill = document.createElement("div"); 170 fill.style.cssText = "height:100%;background:#0073aa;transition:width 100ms linear;border-radius:0;"; 171 fill.className = "meter-fill"; 172 track.appendChild(fill); 173 container.appendChild(track); 174 } else if (type === "line") { 175 container.style.display = "flex"; 176 container.style.alignItems = "flex-end"; 177 var track = document.createElement("div"); 178 track.style.cssText = "width:100%;height:3px;background:rgba(255,255,255,0.1);"; 179 var fill = document.createElement("div"); 180 fill.style.cssText = "height:100%;background:#fff;box-shadow:0 0 8px rgba(255,255,255,0.6);transition:width 100ms linear;"; 181 fill.className = "meter-fill"; 182 track.appendChild(fill); 183 container.appendChild(track); 184 } else if (type === "dots") { 185 container.style.display = "flex"; 186 container.style.alignItems = "flex-end"; 187 container.style.justifyContent = "center"; 188 container.style.gap = "4px"; 189 container.style.paddingBottom = "4px"; 190 for (var i = 0; i < 15; i++) { 191 var dot = document.createElement("div"); 192 dot.style.cssText = "width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,0.2);transition:background 0.2s,transform 0.2s;"; 193 dot.className = "meter-dot"; 194 container.appendChild(dot); 195 } 196 } else if (type === "radial") { 197 container.style.display = "flex"; 198 container.style.alignItems = "center"; 199 container.style.justifyContent = "center"; 200 var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 201 svg.setAttribute("width", "40"); 202 svg.setAttribute("height", "40"); 203 svg.setAttribute("viewBox", "0 0 40 40"); 204 var bgCircle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); 205 bgCircle.setAttribute("cx", "20"); 206 bgCircle.setAttribute("cy", "20"); 207 bgCircle.setAttribute("r", "16"); 208 bgCircle.setAttribute("fill", "none"); 209 bgCircle.setAttribute("stroke", "rgba(255,255,255,0.2)"); 210 bgCircle.setAttribute("stroke-width", "3"); 211 svg.appendChild(bgCircle); 212 var circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); 213 circle.setAttribute("cx", "20"); 214 circle.setAttribute("cy", "20"); 215 circle.setAttribute("r", "16"); 216 circle.setAttribute("fill", "none"); 217 circle.setAttribute("stroke", "#0073aa"); 218 circle.setAttribute("stroke-width", "3"); 219 circle.setAttribute("stroke-linecap", "round"); 220 circle.setAttribute("transform", "rotate(-90 20 20)"); 221 var circumference = 2 * Math.PI * 16; 222 circle.setAttribute("stroke-dasharray", circumference); 223 circle.setAttribute("stroke-dashoffset", circumference); 224 circle.className.baseVal = "meter-ring"; 225 svg.appendChild(circle); 226 container.appendChild(svg); 227 } 228 } 229 230 if (type === "bar" || type === "line") { 231 var fill = container.querySelector(".meter-fill"); 232 if (fill) fill.style.width = (progress * 100) + "%"; 233 } else if (type === "dots") { 234 var dots = container.querySelectorAll(".meter-dot"); 235 var filledCount = Math.floor(progress * dots.length); 236 dots.forEach(function(dot, i) { 237 if (i < filledCount) { 238 dot.style.background = "#0073aa"; 239 dot.style.transform = "scale(1.3)"; 240 } else if (i === filledCount) { 241 dot.style.background = "rgba(0,115,170,0.5)"; 242 dot.style.transform = "scale(1.1)"; 243 } else { 244 dot.style.background = "rgba(255,255,255,0.2)"; 245 dot.style.transform = "scale(1)"; 246 } 247 }); 248 } else if (type === "radial") { 249 var ring = container.querySelector(".meter-ring"); 250 if (ring) { 251 var circumference = 2 * Math.PI * 16; 252 ring.setAttribute("stroke-dashoffset", circumference * (1 - progress)); 253 } 254 } 255 } 256 257 animatePreviews(); 127 258 })(); 128 259 }); … … 209 340 $voting_password = sanitize_text_field( $this->get_post_string( 'voting_password' ) ); 210 341 $click_image_to_zoom = isset( $_POST['click_image_to_zoom'] ) && '1' === $_POST['click_image_to_zoom']; 342 343 $progress_meter_type_input = sanitize_text_field( $this->get_post_string( 'progress_meter_type', 'bar' ) ); 344 if ( ! in_array( $progress_meter_type_input, array( 'bar', 'line', 'dots', 'radial' ), true ) ) { 345 $progress_meter_type_input = 'bar'; 346 } 211 347 212 348 $upload_page_url = sanitize_url( $this->get_post_string( 'upload_page_url', '' ) ); … … 236 372 ), 237 373 'slideshow' => array( 238 'duration_seconds' => 10, 374 'duration_seconds' => 10, 375 'progress_meter_type' => $progress_meter_type_input, 239 376 ), 240 377 'email_reminders' => array( … … 307 444 settings_errors( 'photo_competition_settings' ); 308 445 309 $settings = $this->get_global_settings(); 310 $categories = Competition_Settings::get_categories( $settings ); 311 $grades = Competition_Settings::get_grades( $settings ); 312 $upload = Competition_Settings::get_upload_constraints( $settings ); 313 $voting = Competition_Settings::get_voting_config( $settings ); 314 $voting_ui_type = get_option( 'photo_comp_voting_ui_type', 'buttons' ); 315 $urls = $settings['urls'] ?? array( 446 $settings = $this->get_global_settings(); 447 $categories = Competition_Settings::get_categories( $settings ); 448 $grades = Competition_Settings::get_grades( $settings ); 449 $upload = Competition_Settings::get_upload_constraints( $settings ); 450 $voting = Competition_Settings::get_voting_config( $settings ); 451 $slideshow = $settings['slideshow'] ?? array(); 452 $progress_meter_type = $slideshow['progress_meter_type'] ?? 'bar'; 453 $voting_ui_type = get_option( 'photo_comp_voting_ui_type', 'buttons' ); 454 $urls = $settings['urls'] ?? array( 316 455 'upload_page' => '', 317 456 'voting_page' => '', … … 412 551 echo '<span class="description">' . esc_html__( 'E.g., 9, 8, 7, 6, 5', 'photo-competition-manager' ) . '</span>'; 413 552 echo '</p>'; 553 554 echo '<h2>' . esc_html__( 'Slideshow', 'photo-competition-manager' ) . '</h2>'; 555 556 echo '<p>'; 557 echo '<label>' . esc_html__( 'Progress Meter Style', 'photo-competition-manager' ) . '</label>'; 558 echo '</p>'; 559 560 $meter_types = array( 561 'bar' => __( 'Bar', 'photo-competition-manager' ), 562 'line' => __( 'Thin Line', 'photo-competition-manager' ), 563 'dots' => __( 'Dots', 'photo-competition-manager' ), 564 'radial' => __( 'Radial', 'photo-competition-manager' ), 565 ); 566 567 echo '<div class="progress-meter-selector" style="display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 20px;">'; 568 569 foreach ( $meter_types as $type => $label ) { 570 $is_active = ( $type === $progress_meter_type ) ? ' active' : ''; 571 echo '<label class="progress-meter-card' . esc_attr( $is_active ) . '" style="cursor: pointer; border: 2px solid ' . ( $is_active ? '#0073aa' : '#ddd' ) . '; border-radius: 8px; padding: 12px; text-align: center; background: #1a1a1a; min-width: 140px; transition: border-color 0.2s;">'; 572 echo '<input type="radio" name="progress_meter_type" value="' . esc_attr( $type ) . '"' . checked( $progress_meter_type, $type, false ) . ' style="display: none;" />'; 573 echo '<div class="meter-preview" data-meter-type="' . esc_attr( $type ) . '" style="height: 50px; position: relative; margin-bottom: 8px; overflow: hidden; border-radius: 4px;"></div>'; 574 echo '<span style="color: #666; font-size: 13px; font-weight: 600;">' . esc_html( $label ) . '</span>'; 575 echo '</label>'; 576 } 577 578 echo '</div>'; 579 echo '<span class="description">' . esc_html__( 'Choose the progress indicator style shown during the slideshow.', 'photo-competition-manager' ) . '</span>'; 414 580 415 581 echo '<h2>' . esc_html__( 'Email Configuration', 'photo-competition-manager' ) . '</h2>'; -
photo-competition-manager/tags/0.3.0/admin/class-voting-controller.php
r3446169 r3465730 655 655 656 656 // Render Quick Actions. 657 $this->render_quick_actions( $voting_page_url, $global_settings );657 $this->render_quick_actions( $voting_page_url, $global_settings, $active_settings ); 658 658 659 659 // Hidden duration setting for slideshow. 660 660 echo '<input type="hidden" id="slideshow-duration-setting" value="20" />'; 661 662 // Hidden meter type setting for slideshow. 663 $meter_type = $active_settings['slideshow']['progress_meter_type'] ?? 'bar'; 664 echo '<input type="hidden" id="slideshow-meter-type" value="' . esc_attr( $meter_type ) . '" />'; 661 665 662 666 // Slideshow container (hidden by default). … … 1163 1167 * Render collapsible quick actions bar. 1164 1168 * 1165 * @param string $voting_page_url The voting page URL for QR code. 1166 * @param array $settings Global settings. 1169 * @param string $voting_page_url The voting page URL for QR code. 1170 * @param array $settings Global settings. 1171 * @param array $competition_settings Active competition settings. 1167 1172 * @return void 1168 1173 */ 1169 private function render_quick_actions( string $voting_page_url, array $settings ): void {1174 private function render_quick_actions( string $voting_page_url, array $settings, array $competition_settings = array() ): void { 1170 1175 $results_url = $settings['urls']['results_page'] ?? ''; 1171 1176 $top3_url = $settings['urls']['top3_page'] ?? ''; 1177 1178 // Get voting password if it's stored as plaintext (not a legacy hash). 1179 $voting_password = ''; 1180 $raw_password = $competition_settings['voting']['password'] ?? ''; 1181 if ( '' !== $raw_password && ! preg_match( '/^\$P\$|\$wp\$/', $raw_password ) ) { 1182 $voting_password = $raw_password; 1183 } 1172 1184 ?> 1173 1185 <div class="quick-actions-bar" id="quick-actions"> … … 1200 1212 <?php if ( ! empty( $voting_page_url ) ) : ?> 1201 1213 <div class="qr-code-panel" id="qr-code-panel" style="display: none;"> 1214 <?php if ( '' !== $voting_password ) : ?> 1215 <div class="qr-code-password"> 1216 <span class="qr-code-password-label"><?php esc_html_e( 'Voting Password:', 'photo-competition-manager' ); ?></span> 1217 <span class="qr-code-password-value"><?php echo esc_html( $voting_password ); ?></span> 1218 </div> 1219 <?php endif; ?> 1202 1220 <div class="qr-code-container" data-voting-url="<?php echo esc_attr( $voting_page_url ); ?>"> 1203 1221 <div class="qr-code-canvas"></div> … … 1252 1270 <div class="complete-body"> 1253 1271 <p class="complete-competition"><?php echo esc_html( $competition->title ); ?></p> 1272 <div class="complete-slideshow-section"> 1273 <div class="duration-presets"> 1274 <button type="button" class="button duration-preset" data-duration="5"><?php esc_html_e( '5s', 'photo-competition-manager' ); ?></button> 1275 <button type="button" class="button duration-preset" data-duration="10"><?php esc_html_e( '10s', 'photo-competition-manager' ); ?></button> 1276 <button type="button" class="button duration-preset" data-duration="15"><?php esc_html_e( '15s', 'photo-competition-manager' ); ?></button> 1277 <button type="button" class="button duration-preset active" data-duration="20"><?php esc_html_e( '20s', 'photo-competition-manager' ); ?></button> 1278 <button type="button" class="button duration-preset" data-duration="25"><?php esc_html_e( '25s', 'photo-competition-manager' ); ?></button> 1279 <button type="button" class="button duration-preset" data-duration="30"><?php esc_html_e( '30s', 'photo-competition-manager' ); ?></button> 1280 <button type="button" class="button duration-preset" data-duration="0" title="<?php esc_attr_e( 'Manual: advance with Space or arrow keys', 'photo-competition-manager' ); ?>"><?php esc_html_e( 'Manual', 'photo-competition-manager' ); ?></button> 1281 </div> 1282 </div> 1283 1254 1284 <ul class="complete-categories"> 1255 1285 <?php foreach ( $all_categories as $cat_data ) : ?> 1256 1286 <li class="complete-category-item"> 1257 1287 <span class="dashicons dashicons-yes"></span> 1258 < ?php echo esc_html( $cat_data['category']['label'] ?? '' ); ?>1288 <span class="category-name"><?php echo esc_html( $cat_data['category']['label'] ?? '' ); ?></span> 1259 1289 <span class="category-count">(<?php echo (int) $cat_data['image_count']; ?> <?php esc_html_e( 'images', 'photo-competition-manager' ); ?>)</span> 1290 <span class="category-slideshow-actions"> 1291 <button type="button" class="button button-small photo-competition-manager-start-slideshow" 1292 data-competition-id="<?php echo esc_attr( $competition->id ); ?>" 1293 data-competition-slug="<?php echo esc_attr( $competition->slug ); ?>" 1294 data-category="<?php echo esc_attr( $cat_data['category']['slug'] ?? '' ); ?>" 1295 data-category-label="<?php echo esc_attr( $cat_data['category']['label'] ?? '' ); ?>"> 1296 <span class="dashicons dashicons-slides"></span> <?php esc_html_e( 'Slideshow', 'photo-competition-manager' ); ?> 1297 </button> 1298 <button type="button" class="button button-small photo-competition-manager-start-critique" 1299 data-competition-id="<?php echo esc_attr( $competition->id ); ?>" 1300 data-competition-slug="<?php echo esc_attr( $competition->slug ); ?>" 1301 data-category="<?php echo esc_attr( $cat_data['category']['slug'] ?? '' ); ?>" 1302 data-category-label="<?php echo esc_attr( $cat_data['category']['label'] ?? '' ); ?>" 1303 title="<?php esc_attr_e( 'Manual slideshow for discussion', 'photo-competition-manager' ); ?>"> 1304 <span class="dashicons dashicons-format-chat"></span> <?php esc_html_e( 'Critique', 'photo-competition-manager' ); ?> 1305 </button> 1306 </span> 1260 1307 </li> 1261 1308 <?php endforeach; ?> -
photo-competition-manager/tags/0.3.0/assets/css/admin-slideshow.css
r3415757 r3465730 51 51 .slideshow-image-info { 52 52 position: absolute; 53 bottom: 60px; 54 left: 50%; 55 transform: translateX(-50%); 53 bottom: 20px; 54 left: 20px; 56 55 background: rgba(0, 0, 0, 0.8); 57 56 color: #fff; 58 padding: 1rem 2rem;57 padding: 0.75rem 1.5rem; 59 58 border-radius: 8px; 60 font-size: 2rem;59 font-size: 1.5rem; 61 60 font-weight: 700; 62 61 text-align: center; … … 64 63 } 65 64 65 /* Progress Meter - shared container */ 66 66 .slideshow-progress { 67 67 position: absolute; … … 77 77 background: #0073aa; 78 78 transition: width 100ms linear; 79 } 80 81 /* Progress Meter - Thin Line */ 82 .slideshow-progress.meter-line { 83 height: 3px; 84 background: rgba(255, 255, 255, 0.1); 85 } 86 87 .slideshow-progress.meter-line .progress-bar { 88 background: #fff; 89 box-shadow: 0 0 8px rgba(255, 255, 255, 0.6); 90 } 91 92 /* Progress Meter - Dots */ 93 .slideshow-progress.meter-dots { 94 height: 20px; 95 background: transparent; 96 display: flex; 97 align-items: center; 98 justify-content: center; 99 gap: 6px; 100 } 101 102 .slideshow-progress.meter-dots .progress-bar { 103 display: none; 104 } 105 106 .slideshow-progress.meter-dots .meter-dot { 107 width: 8px; 108 height: 8px; 109 border-radius: 50%; 110 background: rgba(255, 255, 255, 0.2); 111 transition: background 0.2s ease, transform 0.2s ease; 112 flex-shrink: 0; 113 } 114 115 .slideshow-progress.meter-dots .meter-dot.filled { 116 background: #0073aa; 117 transform: scale(1.4); 118 } 119 120 .slideshow-progress.meter-dots .meter-dot.filling { 121 background: rgba(0, 115, 170, 0.5); 122 transform: scale(1.2); 123 } 124 125 /* Progress Meter - Radial */ 126 .slideshow-progress.meter-radial { 127 width: 100%; 128 height: 44px; 129 background: transparent; 130 display: flex; 131 align-items: center; 132 justify-content: center; 133 } 134 135 .slideshow-progress.meter-radial .progress-bar { 136 display: none; 137 } 138 139 .slideshow-progress.meter-radial svg { 140 width: 44px; 141 height: 44px; 142 filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.5)); 143 } 144 145 .slideshow-progress.meter-radial .meter-ring { 146 transition: stroke-dashoffset 100ms linear; 79 147 } 80 148 … … 690 758 } 691 759 760 .qr-code-password { 761 text-align: center; 762 padding: 16px 20px; 763 margin-bottom: 16px; 764 background: #f0f6fc; 765 border: 1px solid #c3c4c7; 766 border-radius: 8px; 767 } 768 769 .qr-code-password-label { 770 display: block; 771 font-size: 14px; 772 font-weight: 600; 773 color: #646970; 774 text-transform: uppercase; 775 letter-spacing: 0.5px; 776 margin-bottom: 8px; 777 } 778 779 .qr-code-password-value { 780 display: block; 781 font-size: 48px; 782 font-weight: 700; 783 color: #1d2327; 784 letter-spacing: 2px; 785 } 786 692 787 .qr-code-details h4 { 693 788 margin: 0 0 8px 0; … … 747 842 748 843 .complete-body { 749 max-width: 500px;844 max-width: 680px; 750 845 margin: 0 auto; 751 846 } … … 782 877 color: #646970; 783 878 font-size: 12px; 879 } 880 881 .complete-category-item .category-slideshow-actions { 784 882 margin-left: auto; 883 display: flex; 884 gap: 6px; 885 flex-shrink: 0; 886 } 887 888 .complete-category-item .category-slideshow-actions .button .dashicons { 889 font-size: 14px; 890 width: 14px; 891 height: 14px; 892 line-height: 14px; 893 position: relative; 894 top: 2px; 895 margin-right: 2px; 896 } 897 898 .complete-slideshow-section { 899 margin-bottom: 16px; 900 display: flex; 901 justify-content: center; 785 902 } 786 903 … … 839 956 @media screen and (max-width: 768px) { 840 957 .slideshow-image-info { 841 font-size: 1.5rem; 842 padding: 0.75rem 1.5rem; 843 bottom: 40px; 958 font-size: 1.2rem; 959 padding: 0.5rem 1rem; 960 bottom: 10px; 961 left: 10px; 844 962 } 845 963 -
photo-competition-manager/tags/0.3.0/assets/css/slideshow.css
r3415757 r3465730 131 131 .slideshow-image-info { 132 132 position: absolute; 133 bottom: 60px; 134 left: 50%; 135 transform: translateX(-50%); 133 bottom: 20px; 134 left: 20px; 136 135 background: rgba(0, 0, 0, 0.8); 137 136 color: #fff; 138 padding: 1rem 2rem;137 padding: 0.75rem 1.5rem; 139 138 border-radius: 8px; 140 font-size: 2rem;139 font-size: 1.5rem; 141 140 font-weight: 700; 142 141 text-align: center; … … 148 147 } 149 148 149 /* Progress Meter - shared container */ 150 150 .slideshow-progress { 151 151 position: absolute; … … 161 161 background: #0073aa; 162 162 transition: width 100ms linear; 163 } 164 165 /* Progress Meter - Thin Line */ 166 .slideshow-progress.meter-line { 167 height: 3px; 168 background: rgba(255, 255, 255, 0.1); 169 } 170 171 .slideshow-progress.meter-line .progress-bar { 172 background: #fff; 173 box-shadow: 0 0 8px rgba(255, 255, 255, 0.6); 174 } 175 176 /* Progress Meter - Dots */ 177 .slideshow-progress.meter-dots { 178 height: 20px; 179 background: transparent; 180 display: flex; 181 align-items: center; 182 justify-content: center; 183 gap: 6px; 184 } 185 186 .slideshow-progress.meter-dots .progress-bar { 187 display: none; 188 } 189 190 .slideshow-progress.meter-dots .meter-dot { 191 width: 8px; 192 height: 8px; 193 border-radius: 50%; 194 background: rgba(255, 255, 255, 0.2); 195 transition: background 0.2s ease, transform 0.2s ease; 196 flex-shrink: 0; 197 } 198 199 .slideshow-progress.meter-dots .meter-dot.filled { 200 background: #0073aa; 201 transform: scale(1.4); 202 } 203 204 .slideshow-progress.meter-dots .meter-dot.filling { 205 background: rgba(0, 115, 170, 0.5); 206 transform: scale(1.2); 207 } 208 209 /* Progress Meter - Radial */ 210 .slideshow-progress.meter-radial { 211 width: 100%; 212 height: 44px; 213 background: transparent; 214 display: flex; 215 align-items: center; 216 justify-content: center; 217 } 218 219 .slideshow-progress.meter-radial .progress-bar { 220 display: none; 221 } 222 223 .slideshow-progress.meter-radial svg { 224 width: 44px; 225 height: 44px; 226 filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.5)); 227 } 228 229 .slideshow-progress.meter-radial .meter-ring { 230 transition: stroke-dashoffset 100ms linear; 163 231 } 164 232 … … 213 281 214 282 .slideshow-image-info { 215 font-size: 1.5rem; 216 padding: 0.75rem 1.5rem; 217 bottom: 40px; 283 font-size: 1.2rem; 284 padding: 0.5rem 1rem; 285 bottom: 10px; 286 left: 10px; 218 287 } 219 288 -
photo-competition-manager/tags/0.3.0/assets/js/admin-slideshow.js
r3415757 r3465730 34 34 // Image pre-caching 35 35 this.imageCache = new Map(); 36 this.meterType = $('#slideshow-meter-type').val() || 'bar'; 36 37 37 38 this.bindEvents(); … … 47 48 } 48 49 return seconds * 1000; 50 } 51 52 createMeterRenderer(type) { 53 const $progress = this.$display.find('.slideshow-progress'); 54 const $progressBar = this.$progressBar; 55 56 if (type === 'line') { 57 $progress.addClass('meter-line'); 58 return { 59 update(progress) { $progressBar.css('width', progress + '%'); }, 60 reset() { $progressBar.css('width', '0%'); }, 61 destroy() { $progress.removeClass('meter-line'); } 62 }; 63 } 64 65 if (type === 'dots') { 66 $progress.addClass('meter-dots'); 67 for (let i = 0; i < 20; i++) { 68 $progress.append('<div class="meter-dot"></div>'); 69 } 70 const $dots = $progress.find('.meter-dot'); 71 return { 72 update(progress) { 73 const filledCount = Math.floor(progress / 100 * $dots.length); 74 $dots.each(function(i) { 75 const $dot = $(this); 76 $dot.removeClass('filled filling'); 77 if (i < filledCount) { 78 $dot.addClass('filled'); 79 } else if (i === filledCount) { 80 $dot.addClass('filling'); 81 } 82 }); 83 }, 84 reset() { $dots.removeClass('filled filling'); }, 85 destroy() { $dots.remove(); $progress.removeClass('meter-dots'); } 86 }; 87 } 88 89 if (type === 'radial') { 90 $progress.addClass('meter-radial'); 91 const circumference = 2 * Math.PI * 16; 92 const svg = '<svg width="44" height="44" viewBox="0 0 44 44">' + 93 '<circle cx="22" cy="22" r="16" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' + 94 '<circle class="meter-ring" cx="22" cy="22" r="16" fill="none" stroke="#0073aa" stroke-width="3" stroke-linecap="round" transform="rotate(-90 22 22)" stroke-dasharray="' + circumference + '" stroke-dashoffset="' + circumference + '"/>' + 95 '</svg>'; 96 $progress.append(svg); 97 const $ring = $progress.find('.meter-ring'); 98 return { 99 update(progress) { $ring.attr('stroke-dashoffset', circumference * (1 - progress / 100)); }, 100 reset() { $ring.attr('stroke-dashoffset', circumference); }, 101 destroy() { $progress.find('svg').remove(); $progress.removeClass('meter-radial'); } 102 }; 103 } 104 105 // Default: bar 106 return { 107 update(progress) { $progressBar.css('width', progress + '%'); }, 108 reset() { $progressBar.css('width', '0%'); }, 109 destroy() {} 110 }; 49 111 } 50 112 … … 272 334 273 335 // Show first image (pass true to start timer) 336 this.meterRenderer = this.createMeterRenderer(this.meterType); 274 337 this.showImage(0, true); 275 338 … … 285 348 this.currentIndex = 0; 286 349 this.$display.css('display', 'flex'); 350 this.meterRenderer = this.createMeterRenderer(this.meterType); 287 351 this.showImage(0, true); 288 352 setTimeout(function() { … … 333 397 // Hide display 334 398 this.$display.fadeOut(300); 335 this.$progressBar.css('width', '0%'); 399 if (this.meterRenderer) { 400 this.meterRenderer.reset(); 401 this.meterRenderer.destroy(); 402 } 336 403 this.$pauseBtn.show(); 337 404 this.$resumeBtn.hide(); … … 359 426 360 427 // Reset progress bar 361 this.$progressBar.css('width', '0%');428 if (this.meterRenderer) this.meterRenderer.reset(); 362 429 this.startTime = Date.now(); 363 430 … … 376 443 this.$image.attr('alt', 'Image #' + image.random_number); 377 444 this.$imageInfo.find('.image-number').text('#' + image.random_number); 378 this.$progressBar.css('width', '0%');445 if (this.meterRenderer) this.meterRenderer.reset(); 379 446 this.startTime = Date.now(); 380 447 … … 419 486 if (duration === 0) { 420 487 // Hide progress bar in manual mode 421 this.$progressBar.css('width', '0%');488 if (this.meterRenderer) this.meterRenderer.reset(); 422 489 return; 423 490 } … … 433 500 const elapsed = Date.now() - self.startTime; 434 501 const progress = Math.min((elapsed / duration) * 100, 100); 435 self.$progressBar.css('width', progress + '%');502 if (self.meterRenderer) self.meterRenderer.update(progress); 436 503 }, 100); 437 504 } -
photo-competition-manager/tags/0.3.0/assets/js/slideshow.js
r3415757 r3465730 24 24 this.nonce = this.$container.data('nonce'); 25 25 this.ajaxUrl = this.$container.data('ajax-url'); 26 this.meterType = this.$container.data('meter-type') || 'bar'; 26 27 this.images = this.$container.data('images'); 27 28 … … 85 86 } 86 87 88 createMeterRenderer(type) { 89 const $progress = this.$container.find('.slideshow-progress'); 90 const $progressBar = this.$progressBar; 91 92 if (type === 'line') { 93 $progress.addClass('meter-line'); 94 return { 95 update(progress) { $progressBar.css('width', progress + '%'); }, 96 reset() { $progressBar.css('width', '0%'); }, 97 destroy() { $progress.removeClass('meter-line'); } 98 }; 99 } 100 101 if (type === 'dots') { 102 $progress.addClass('meter-dots'); 103 for (let i = 0; i < 20; i++) { 104 $progress.append('<div class="meter-dot"></div>'); 105 } 106 const $dots = $progress.find('.meter-dot'); 107 return { 108 update(progress) { 109 const filledCount = Math.floor(progress / 100 * $dots.length); 110 $dots.each(function(i) { 111 const $dot = $(this); 112 $dot.removeClass('filled filling'); 113 if (i < filledCount) { 114 $dot.addClass('filled'); 115 } else if (i === filledCount) { 116 $dot.addClass('filling'); 117 } 118 }); 119 }, 120 reset() { 121 $dots.removeClass('filled filling'); 122 }, 123 destroy() { 124 $dots.remove(); 125 $progress.removeClass('meter-dots'); 126 } 127 }; 128 } 129 130 if (type === 'radial') { 131 $progress.addClass('meter-radial'); 132 const circumference = 2 * Math.PI * 16; 133 const svg = '<svg width="44" height="44" viewBox="0 0 44 44">' + 134 '<circle cx="22" cy="22" r="16" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' + 135 '<circle class="meter-ring" cx="22" cy="22" r="16" fill="none" stroke="#0073aa" stroke-width="3" stroke-linecap="round" transform="rotate(-90 22 22)" stroke-dasharray="' + circumference + '" stroke-dashoffset="' + circumference + '"/>' + 136 '</svg>'; 137 $progress.append(svg); 138 const $ring = $progress.find('.meter-ring'); 139 return { 140 update(progress) { 141 $ring.attr('stroke-dashoffset', circumference * (1 - progress / 100)); 142 }, 143 reset() { 144 $ring.attr('stroke-dashoffset', circumference); 145 }, 146 destroy() { 147 $progress.find('svg').remove(); 148 $progress.removeClass('meter-radial'); 149 } 150 }; 151 } 152 153 // Default: 'bar' 154 return { 155 update(progress) { $progressBar.css('width', progress + '%'); }, 156 reset() { $progressBar.css('width', '0%'); }, 157 destroy() {} 158 }; 159 } 160 87 161 start() { 88 162 if (this.images.length === 0) { … … 105 179 106 180 // Show first image 181 this.meterRenderer = this.createMeterRenderer(this.meterType); 107 182 this.showImage(0); 108 183 this.startAutoAdvance(); … … 120 195 this.$display.fadeIn(300); 121 196 this.$statusMessage.text('Slideshow running...'); 197 this.meterRenderer = this.createMeterRenderer(this.meterType); 122 198 this.showImage(0); 123 199 this.startAutoAdvance(); … … 165 241 this.$display.fadeOut(300); 166 242 this.$statusMessage.text(response.message || 'Slideshow stopped'); 167 this.$progressBar.css('width', '0%'); 243 if (this.meterRenderer) { 244 this.meterRenderer.reset(); 245 this.meterRenderer.destroy(); 246 } 168 247 169 248 // Exit fullscreen … … 223 302 224 303 // Reset progress bar 225 this.$progressBar.css('width', '0%');304 if (this.meterRenderer) this.meterRenderer.reset(); 226 305 this.startTime = Date.now(); 227 306 } … … 258 337 this.$display.fadeOut(300); 259 338 this.$statusMessage.text(response.message || 'Slideshow ended'); 260 this.$progressBar.css('width', '0%'); 339 if (this.meterRenderer) { 340 this.meterRenderer.reset(); 341 this.meterRenderer.destroy(); 342 } 261 343 262 344 // Exit fullscreen … … 298 380 if (intervalMs === 0) { 299 381 // Hide progress bar in manual mode 300 this.$progressBar.css('width', '0%');382 if (this.meterRenderer) this.meterRenderer.reset(); 301 383 return; 302 384 } … … 312 394 const elapsed = Date.now() - this.startTime; 313 395 const progress = Math.min((elapsed / intervalMs) * 100, 100); 314 this.$progressBar.css('width', progress + '%');396 if (this.meterRenderer) this.meterRenderer.update(progress); 315 397 }, 100); 316 398 } -
photo-competition-manager/tags/0.3.0/includes/Install/class-activator.php
r3415757 r3465730 93 93 grade VARCHAR(100) NOT NULL, 94 94 active TINYINT(1) NOT NULL DEFAULT 1, 95 committee TINYINT(1) NOT NULL DEFAULT 0, 95 96 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 96 97 updated_at DATETIME NULL, … … 106 107 close_date DATETIME NULL, 107 108 settings LONGTEXT NULL, 109 share_hash VARCHAR(64) NOT NULL DEFAULT '', 108 110 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 109 111 updated_at DATETIME NULL, 110 112 deleted_at DATETIME NULL, 111 113 PRIMARY KEY (id), 112 UNIQUE KEY slug (slug) 114 UNIQUE KEY slug (slug), 115 KEY share_hash (share_hash) 113 116 ) {$charset_collate};"; 114 117 -
photo-competition-manager/tags/0.3.0/includes/Repository/class-competitions-repository.php
r3446169 r3465730 185 185 'close_date' => $close_date, 186 186 'settings' => isset( $data['settings'] ) ? wp_json_encode( $data['settings'] ) : null, 187 'share_hash' => isset( $data['share_hash'] ) ? sanitize_text_field( (string) $data['share_hash'] ) : '', 187 188 'created_at' => $now, 188 189 'updated_at' => $now, … … 190 191 191 192 $format = array( 193 '%s', 192 194 '%s', 193 195 '%s', … … 399 401 if ( false === $deleted ) { 400 402 return new WP_Error( 'db_delete_failed', __( 'Could not delete competition.', 'photo-competition-manager' ), $wpdb->last_error ); 403 } 404 405 return true; 406 } 407 408 /** 409 * Find a competition by its share hash. 410 * 411 * @param string $share_hash Share hash to look up. 412 * @return object|null 413 */ 414 public function find_by_share_hash( string $share_hash ) { 415 global $wpdb; 416 417 if ( empty( $share_hash ) ) { 418 return null; 419 } 420 421 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching 422 return $wpdb->get_row( 423 $wpdb->prepare( 424 'SELECT * FROM %i WHERE share_hash = %s AND deleted_at IS NULL LIMIT 1', 425 $this->table(), 426 $share_hash 427 ) 428 ); 429 } 430 431 /** 432 * Update the share hash for a competition. 433 * 434 * @param int $id Competition ID. 435 * @param string $share_hash New share hash. 436 * @return bool|WP_Error 437 */ 438 public function update_share_hash( int $id, string $share_hash ) { 439 global $wpdb; 440 441 if ( $id <= 0 ) { 442 return new WP_Error( 'invalid_competition', __( 'Competition not found.', 'photo-competition-manager' ) ); 443 } 444 445 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching 446 $updated = $wpdb->update( 447 $this->table(), 448 array( 449 'share_hash' => $share_hash, 450 'updated_at' => utc_time(), 451 ), 452 array( 'id' => $id ), 453 array( '%s', '%s' ), 454 array( '%d' ) 455 ); 456 457 if ( false === $updated ) { 458 return new WP_Error( 'db_update_failed', __( 'Could not update share hash.', 'photo-competition-manager' ), $wpdb->last_error ); 401 459 } 402 460 -
photo-competition-manager/tags/0.3.0/includes/Repository/class-members-repository.php
r3446169 r3465730 148 148 } 149 149 150 $grade = isset( $data['grade'] ) ? sanitize_text_field( (string) $data['grade'] ) : ''; 151 $active = isset( $data['active'] ) ? (int) (bool) $data['active'] : 1; 152 $now = utc_time(); 150 $grade = isset( $data['grade'] ) ? sanitize_text_field( (string) $data['grade'] ) : ''; 151 $active = isset( $data['active'] ) ? (int) (bool) $data['active'] : 1; 152 $committee = isset( $data['committee'] ) ? (int) (bool) $data['committee'] : 0; 153 $now = utc_time(); 153 154 154 155 $payload = array( … … 157 158 'grade' => $grade, 158 159 'active' => $active, 160 'committee' => $committee, 159 161 'created_at' => $now, 160 162 'updated_at' => $now, 161 163 ); 162 164 163 $format = array( '%s', '%s', '%s', '%d', '% s', '%s' );165 $format = array( '%s', '%s', '%s', '%d', '%d', '%s', '%s' ); 164 166 165 167 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching … … 209 211 } 210 212 211 $grade = array_key_exists( 'grade', $data ) ? sanitize_text_field( (string) $data['grade'] ) : $current->grade; 212 $active = array_key_exists( 'active', $data ) ? (int) (bool) $data['active'] : (int) $current->active; 213 $grade = array_key_exists( 'grade', $data ) ? sanitize_text_field( (string) $data['grade'] ) : $current->grade; 214 $active = array_key_exists( 'active', $data ) ? (int) (bool) $data['active'] : (int) $current->active; 215 $committee = array_key_exists( 'committee', $data ) ? (int) (bool) $data['committee'] : (int) ( $current->committee ?? 0 ); 213 216 214 217 $payload = array( … … 217 220 'grade' => $grade, 218 221 'active' => $active, 222 'committee' => $committee, 219 223 'updated_at' => utc_time(), 220 224 ); 221 225 222 $format = array( '%s', '%s', '%s', '%d', '% s' );226 $format = array( '%s', '%s', '%s', '%d', '%d', '%s' ); 223 227 224 228 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching … … 239 243 240 244 /** 245 * Fetch active committee members. 246 * 247 * @return array<int, object> 248 */ 249 public function find_committee_members(): array { 250 global $wpdb; 251 252 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching 253 return $wpdb->get_results( 254 $wpdb->prepare( 255 'SELECT * FROM %i WHERE committee = 1 AND active = 1 ORDER BY name ASC', 256 $this->table() 257 ) 258 ); 259 } 260 261 /** 262 * Fetch all active members (unbounded). 263 * 264 * @return array<int, object> 265 */ 266 public function find_active_members(): array { 267 global $wpdb; 268 269 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching 270 return $wpdb->get_results( 271 $wpdb->prepare( 272 'SELECT * FROM %i WHERE active = 1 ORDER BY name ASC', 273 $this->table() 274 ) 275 ); 276 } 277 278 /** 241 279 * Toggle active flag. 242 280 * -
photo-competition-manager/tags/0.3.0/includes/Service/class-email-service.php
r3446169 r3465730 323 323 * @param int $quota Maximum allowed submissions. 324 324 * @param int|null $competition_id Optional competition ID for logging. 325 * @param string $member_grade Member grade. 325 326 * @return bool Whether email was sent successfully. 326 327 */ … … 332 333 int $current_count, 333 334 int $quota, 334 ?int $competition_id = null 335 ?int $competition_id = null, 336 string $member_grade = '' 335 337 ): bool { 336 338 $template = $this->get_template( 'submission_confirmed' ); … … 342 344 $merge_data = array( 343 345 '{member_name}' => $member_name, 346 '{member_grade}' => $member_grade, 344 347 '{competition_title}' => $competition_title, 345 348 '{category_name}' => $category_name, … … 524 527 525 528 return $result; 529 } 530 531 /** 532 * Send results share link email. 533 * 534 * @param string $to_email Recipient email address. 535 * @param string $member_name Member name. 536 * @param string $competition_title Competition title. 537 * @param string $share_url Results page URL with share hash. 538 * @param int|null $competition_id Optional competition ID for logging. 539 * @return bool Whether the email was sent successfully. 540 */ 541 public function send_results_share_link( 542 string $to_email, 543 string $member_name, 544 string $competition_title, 545 string $share_url, 546 ?int $competition_id = null 547 ): bool { 548 $template = $this->get_template( 'results_published' ); 549 550 if ( $template && $template['enabled'] ) { 551 $merge_data = array( 552 '{member_name}' => $member_name, 553 '{competition_title}' => $competition_title, 554 '{results_share_link}' => $share_url, 555 '{results_page}' => $share_url, 556 '{site_name}' => get_bloginfo( 'name' ), 557 ); 558 559 $subject = $this->replace_merge_tags( $template['subject'], $merge_data ); 560 $message = $this->replace_merge_tags( $template['body'], $merge_data ); 561 $message = $this->wrap_html_email( $message ); 562 } else { 563 $subject = sprintf( 564 /* translators: %s: Competition title */ 565 __( 'Results for %s', 'photo-competition-manager' ), 566 $competition_title 567 ); 568 $message = $this->get_results_share_link_email_body( $member_name, $competition_title, $share_url ); 569 } 570 571 $headers = array( 'Content-Type: text/html; charset=UTF-8' ); 572 573 $result = $this->send_mail( $to_email, $this->prefix_subject( $subject ), $message, $headers ); 574 575 if ( $result && $this->event_logger ) { 576 $this->event_logger->log_email_sent( 577 $competition_id, 578 'results_share_link', 579 $member_name, 580 array( 'email' => $to_email ) 581 ); 582 } 583 584 return $result; 585 } 586 587 /** 588 * Get results share link email body. 589 * 590 * @param string $member_name Member name. 591 * @param string $competition_title Competition title. 592 * @param string $share_url Share URL. 593 * @return string 594 */ 595 private function get_results_share_link_email_body( string $member_name, string $competition_title, string $share_url ): string { 596 ob_start(); 597 ?> 598 <!DOCTYPE html> 599 <html> 600 <head> 601 <meta charset="UTF-8"> 602 </head> 603 <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> 604 <div style="max-width: 600px; margin: 0 auto; padding: 20px;"> 605 <h2 style="color: #0073aa;"><?php echo esc_html( $competition_title ); ?> - <?php esc_html_e( 'Results', 'photo-competition-manager' ); ?></h2> 606 607 <p> 608 <?php 609 printf( 610 /* translators: %s: Member name */ 611 esc_html__( 'Hi %s,', 'photo-competition-manager' ), 612 esc_html( $member_name ) 613 ); 614 ?> 615 </p> 616 617 <p><?php esc_html_e( 'The results for this competition are now available. Click the button below to view them:', 'photo-competition-manager' ); ?></p> 618 619 <p style="margin: 30px 0;"> 620 <a href="<?php echo esc_url( $share_url ); ?>" 621 style="background-color: #0073aa; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;"> 622 <?php esc_html_e( 'View Results', 'photo-competition-manager' ); ?> 623 </a> 624 </p> 625 626 <p style="color: #666; font-size: 14px;"> 627 <?php esc_html_e( 'This is a private link. Please do not share it publicly.', 'photo-competition-manager' ); ?> 628 </p> 629 630 <hr style="border: none; border-top: 1px solid #ddd; margin: 30px 0;"> 631 632 <p style="color: #999; font-size: 12px;"> 633 <?php 634 printf( 635 /* translators: %s: Site name */ 636 esc_html__( 'This email was sent by %s', 'photo-competition-manager' ), 637 esc_html( get_bloginfo( 'name' ) ) 638 ); 639 ?> 640 </p> 641 </div> 642 </body> 643 </html> 644 <?php 645 return ob_get_clean(); 526 646 } 527 647 -
photo-competition-manager/tags/0.3.0/includes/Service/class-member-csv-importer.php
r3415757 r3465730 102 102 103 103 // Get column indexes. 104 $col_name = array_search( 'name', $header, true ); 105 $col_email = array_search( 'email', $header, true ); 106 $col_grade = array_search( 'grade', $header, true ); 107 $col_active = array_search( 'active', $header, true ); 104 $col_name = array_search( 'name', $header, true ); 105 $col_email = array_search( 'email', $header, true ); 106 $col_grade = array_search( 'grade', $header, true ); 107 $col_active = array_search( 'active', $header, true ); 108 $col_committee = array_search( 'committee', $header, true ); 108 109 109 110 $row_number = 1; // Start at 1 (header is row 0). 110 111 } else { 111 // First row is data - assume format: name,email or name,email,grade,active .112 // First row is data - assume format: name,email or name,email,grade,active,committee. 112 113 // Rewind to process first row as data. 113 114 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rewind 114 115 rewind( $handle ); 115 116 116 // Assume columns: name, email, grade (optional), active (optional). 117 $col_name = 0; 118 $col_email = 1; 119 $col_grade = 2; 120 $col_active = 3; 117 // Assume columns: name, email, grade (optional), active (optional), committee (optional). 118 $col_name = 0; 119 $col_email = 1; 120 $col_grade = 2; 121 $col_active = 3; 122 $col_committee = 4; 121 123 122 124 $row_number = 0; // Start at 0 since there's no header. … … 140 142 $email = isset( $row[ $col_email ] ) ? sanitize_email( trim( $row[ $col_email ] ) ) : ''; 141 143 $grade = false !== $col_grade && isset( $row[ $col_grade ] ) ? sanitize_text_field( trim( $row[ $col_grade ] ) ) : ''; 142 $active = false !== $col_active && isset( $row[ $col_active ] ) ? trim( $row[ $col_active ] ) : '1'; 144 $active = false !== $col_active && isset( $row[ $col_active ] ) ? trim( $row[ $col_active ] ) : '1'; 145 $committee = false !== $col_committee && isset( $row[ $col_committee ] ) ? trim( $row[ $col_committee ] ) : '0'; 143 146 144 147 // Validate required fields. … … 168 171 169 172 // Normalize active field (1, yes, true, active = true; others = false). 170 $active_normalized = in_array( strtolower( $active ), array( '1', 'yes', 'true', 'active' ), true ) ? 1 : 0; 173 $active_normalized = in_array( strtolower( $active ), array( '1', 'yes', 'true', 'active' ), true ) ? 1 : 0; 174 $committee_normalized = in_array( strtolower( $committee ), array( '1', 'yes', 'true' ), true ) ? 1 : 0; 171 175 172 176 // Check if member already exists by email. … … 174 178 175 179 $data = array( 176 'name' => $name, 177 'email' => $email, 178 'grade' => $grade, 179 'active' => $active_normalized, 180 'name' => $name, 181 'email' => $email, 182 'grade' => $grade, 183 'active' => $active_normalized, 184 'committee' => $committee_normalized, 180 185 ); 181 186 … … 226 231 */ 227 232 public function generate_sample_csv(): string { 228 $csv = "name,email,grade,active \n";229 $csv .= '"John Doe",john.doe@example.com,Beginner,1 ' . "\n";230 $csv .= '"Jane Smith",jane.smith@example.com,Advanced,1 ' . "\n";231 $csv .= '"Bob Johnson",bob.johnson@example.com,Intermediate,0 ' . "\n";233 $csv = "name,email,grade,active,committee\n"; 234 $csv .= '"John Doe",john.doe@example.com,Beginner,1,0' . "\n"; 235 $csv .= '"Jane Smith",jane.smith@example.com,Advanced,1,1' . "\n"; 236 $csv .= '"Bob Johnson",bob.johnson@example.com,Intermediate,0,0' . "\n"; 232 237 233 238 return $csv; -
photo-competition-manager/tags/0.3.0/includes/Service/class-upload-handler.php
r3415757 r3465730 191 191 $category_config['label'], 192 192 $counter, 193 $quota 193 $quota, 194 null, 195 ! empty( $member->grade ) ? $member->grade : '' 194 196 ); 195 197 -
photo-competition-manager/tags/0.3.0/includes/Support/class-competition-settings.php
r3415757 r3465730 92 92 ), 93 93 'slideshow' => array( 94 'duration_seconds' => 10, 94 'duration_seconds' => 10, 95 'progress_meter_type' => 'bar', 95 96 ), 96 97 'email_reminders' => array( … … 108 109 ), 109 110 ); 111 } 112 113 /** 114 * Generate a 32-character hex share hash. 115 * 116 * @return string 117 */ 118 public static function generate_share_hash(): string { 119 return bin2hex( random_bytes( 16 ) ); 110 120 } 111 121 … … 269 279 } 270 280 281 if ( isset( $settings['slideshow']['progress_meter_type'] ) ) { 282 $valid_meter_types = array( 'bar', 'line', 'dots', 'radial' ); 283 if ( ! in_array( $settings['slideshow']['progress_meter_type'], $valid_meter_types, true ) ) { 284 return new WP_Error( 'invalid_meter_type', __( 'Progress meter type must be "bar", "line", "dots", or "radial".', 'photo-competition-manager' ) ); 285 } 286 } 287 271 288 return true; 272 289 } -
photo-competition-manager/tags/0.3.0/includes/bootstrap.php
r3415757 r3465730 12 12 // Define plugin version. 13 13 if ( ! defined( 'PHOTO_COMPETITION_MANAGER_VERSION' ) ) { 14 define( 'PHOTO_COMPETITION_MANAGER_VERSION', '0. 1.0' );14 define( 'PHOTO_COMPETITION_MANAGER_VERSION', '0.3.0' ); 15 15 } 16 16 -
photo-competition-manager/tags/0.3.0/photo-competition-manager.php
r3446169 r3465730 3 3 * Plugin Name: Photo Competition Manager 4 4 * Description: Manage photography competitions, submissions, and voting. 5 * Version: 0. 2.05 * Version: 0.3.0 6 6 * Author: Donncha O Caoimh 7 7 * License: GPL2 -
photo-competition-manager/tags/0.3.0/public/class-results-shortcode.php
r3415757 r3465730 103 103 ); 104 104 105 $share_hash = isset( $_GET['share'] ) ? sanitize_text_field( wp_unslash( $_GET['share'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read-only parameter for share link access. 105 106 $competition = null; 106 107 // If no competition specified, get the most recent one. 107 $valid_share = false; 108 108 109 if ( empty( $atts['competition'] ) ) { 109 $competitions = $this->competitions_repo->all( 1, false, false ); 110 if ( empty( $competitions ) ) { 111 return '<p class="error">' . esc_html__( 'No competitions found.', 'photo-competition-manager' ) . '</p>'; 112 } 113 $competition = $competitions[0]; 110 // When a share hash is provided, resolve the competition it belongs to. 111 if ( ! empty( $share_hash ) ) { 112 $competition = $this->competitions_repo->find_by_share_hash( $share_hash ); 113 if ( $competition ) { 114 $valid_share = true; 115 } 116 } 117 118 // Fall back to the most recent competition. 119 if ( ! $competition ) { 120 $competitions = $this->competitions_repo->all( 1, false, false ); 121 if ( empty( $competitions ) ) { 122 return '<p class="error">' . esc_html__( 'No competitions found.', 'photo-competition-manager' ) . '</p>'; 123 } 124 $competition = $competitions[0]; 125 } 114 126 } else { 115 127 $competition = $this->competitions_repo->find_by_slug( $atts['competition'] ); … … 117 129 return '<p class="error">' . esc_html__( 'Competition not found.', 'photo-competition-manager' ) . '</p>'; 118 130 } 131 132 // Validate share hash against the explicit competition. 133 if ( ! empty( $share_hash ) ) { 134 $stored_hash = $competition->share_hash ?? ''; 135 $valid_share = ! empty( $stored_hash ) && hash_equals( $stored_hash, $share_hash ); 136 } 119 137 } 120 138 … … 122 140 123 141 ob_start(); 124 $this->render_results( $competition, $hide_names );142 $this->render_results( $competition, $hide_names, $valid_share ); 125 143 $output = ob_get_clean(); 126 144 return false !== $output ? $output : ''; … … 130 148 * Render results display. 131 149 * 132 * @param object $competition Competition object. 133 * @param bool $hide_names Whether to hide member names. 150 * @param object $competition Competition object. 151 * @param bool $hide_names Whether to hide member names. 152 * @param bool $valid_share Whether a valid share hash was provided. 134 153 * @return void 135 154 */ 136 private function render_results( object $competition, bool $hide_names = false ): void {155 private function render_results( object $competition, bool $hide_names = false, bool $valid_share = false ): void { 137 156 $settings = Competition_Settings::parse( $competition->settings ); 138 157 $grades = Competition_Settings::get_grades( $settings ); 139 158 $categories = Competition_Settings::get_categories( $settings ); 140 159 141 // Check if results are visible.142 160 $results_visible = $settings['results']['results_visible'] ?? false; 143 161 144 if ( ! $results_visible ) {162 if ( ! $results_visible && ! $valid_share ) { 145 163 echo '<div class="photo-comp-results">'; 146 164 echo '<h2>' . esc_html( $competition->title ) . ' - ' . esc_html__( 'Results', 'photo-competition-manager' ) . '</h2>'; -
photo-competition-manager/tags/0.3.0/public/class-slideshow-shortcode.php
r3415757 r3465730 149 149 // Output slideshow interface. 150 150 ob_start(); 151 $this->render_slideshow_interface( $competition, $category, $category_label, $image_data );151 $this->render_slideshow_interface( $competition, $category, $category_label, $image_data, $settings ); 152 152 $output = ob_get_clean(); 153 153 … … 161 161 * @param string $category Category slug. 162 162 * @param string $category_label Category label. 163 * @param array<int, array> $image_data Image data for JavaScript. 164 * @return void 165 */ 166 private function render_slideshow_interface( object $competition, string $category, string $category_label, array $image_data ): void { 163 * @param array<int, array> $image_data Image data for JavaScript. 164 * @param array<string, mixed> $settings Competition settings. 165 * @return void 166 */ 167 private function render_slideshow_interface( object $competition, string $category, string $category_label, array $image_data, array $settings ): void { 167 168 $nonce = wp_create_nonce( 'photo_comp_slideshow' ); 168 169 ?> … … 172 173 data-nonce="<?php echo esc_attr( $nonce ); ?>" 173 174 data-ajax-url="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>" 174 data-images="<?php echo esc_attr( wp_json_encode( $image_data ) ); ?>"> 175 data-images="<?php echo esc_attr( wp_json_encode( $image_data ) ); ?>" 176 data-meter-type="<?php echo esc_attr( $settings['slideshow']['progress_meter_type'] ?? 'bar' ); ?>"> 175 177 176 178 <!-- Control Panel --> -
photo-competition-manager/tags/0.3.0/public/class-top3-shortcode.php
r3415757 r3465730 102 102 ); 103 103 104 $share_hash = isset( $_GET['share'] ) ? sanitize_text_field( wp_unslash( $_GET['share'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read-only parameter for share link access. 104 105 $competition = null; 105 106 // If no competition specified, get the most recent one. 106 $valid_share = false; 107 107 108 if ( empty( $atts['competition'] ) ) { 108 $competitions = $this->competitions_repo->all( 1, false, false ); 109 if ( empty( $competitions ) ) { 110 return '<p class="error">' . esc_html__( 'No competitions found.', 'photo-competition-manager' ) . '</p>'; 111 } 112 $competition = $competitions[0]; 109 // When a share hash is provided, resolve the competition it belongs to. 110 if ( ! empty( $share_hash ) ) { 111 $competition = $this->competitions_repo->find_by_share_hash( $share_hash ); 112 if ( $competition ) { 113 $valid_share = true; 114 } 115 } 116 117 // Fall back to the most recent competition. 118 if ( ! $competition ) { 119 $competitions = $this->competitions_repo->all( 1, false, false ); 120 if ( empty( $competitions ) ) { 121 return '<p class="error">' . esc_html__( 'No competitions found.', 'photo-competition-manager' ) . '</p>'; 122 } 123 $competition = $competitions[0]; 124 } 113 125 } else { 114 126 $competition = $this->competitions_repo->find_by_slug( $atts['competition'] ); … … 116 128 return '<p class="error">' . esc_html__( 'Competition not found.', 'photo-competition-manager' ) . '</p>'; 117 129 } 130 131 // Validate share hash against the explicit competition. 132 if ( ! empty( $share_hash ) ) { 133 $stored_hash = $competition->share_hash ?? ''; 134 $valid_share = ! empty( $stored_hash ) && hash_equals( $stored_hash, $share_hash ); 135 } 118 136 } 119 137 120 138 ob_start(); 121 $this->render_top3_results( $competition );139 $this->render_top3_results( $competition, $valid_share ); 122 140 $output = ob_get_clean(); 123 141 return $output ? $output : ''; … … 127 145 * Render top 3 results display. 128 146 * 129 * @param object $competition Competition object. 147 * @param object $competition Competition object. 148 * @param bool $valid_share Whether a valid share hash was provided. 130 149 * @return void 131 150 */ 132 private function render_top3_results( object $competition ): void {151 private function render_top3_results( object $competition, bool $valid_share = false ): void { 133 152 $settings = Competition_Settings::parse( $competition->settings ); 134 153 $grades = Competition_Settings::get_grades( $settings ); 135 154 $categories = Competition_Settings::get_categories( $settings ); 136 155 137 // Check if results are visible.138 156 $results_visible = $settings['results']['results_visible'] ?? false; 139 157 140 if ( ! $results_visible ) {158 if ( ! $results_visible && ! $valid_share ) { 141 159 echo '<div class="photo-comp-top3">'; 142 160 echo '<h2>' . esc_html( $competition->title ) . ' - ' . esc_html__( 'Top 3 Winners', 'photo-competition-manager' ) . '</h2>'; -
photo-competition-manager/tags/0.3.0/public/class-voting-shortcode.php
r3415757 r3465730 498 498 } 499 499 500 // Lowercase the provided password for case-insensitive comparison. 501 if ( ! wp_check_password( strtolower( $provided_pass ), $expected_password ) ) { 500 // Try direct case-insensitive comparison first (plaintext), fall back to wp_check_password for legacy hashes. 501 $password_matches = strtolower( $provided_pass ) === strtolower( $expected_password ) 502 || wp_check_password( strtolower( $provided_pass ), $expected_password ); 503 if ( ! $password_matches ) { 502 504 return array( 503 505 'status' => 'error', -
photo-competition-manager/tags/0.3.0/readme.txt
r3446169 r3465730 5 5 Tested up to: 6.9 6 6 Requires PHP: 7.4 7 Stable tag: 0. 2.07 Stable tag: 0.3.0 8 8 License: GPLv2 or later 9 9 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 155 155 == Changelog == 156 156 157 = 0.3.0 = 158 * Fix fatal error on activation due to missing Admin_Dependencies class in release package 159 * **Results Sharing** — Share competition results via a secret link before making them public 160 * New "Generate Results Link" action on the Competitions page 161 * "Send to Committee" and "Send to All Members" buttons on the Results Dashboard 162 * Share link bypasses results visibility and resolves to the correct competition 163 * **Committee Members** — Mark members as committee via admin or CSV import 164 * Confirmation dialogs on hash regeneration and email sending to prevent accidental actions 165 157 166 = 0.2.0 = 158 167 * **Voting Controls Redesign** … … 223 232 == Upgrade Notice == 224 233 234 = 0.3.0 = 235 Fixes a fatal error on plugin activation. Share competition results with committee members or all members via a secret link before making results public. 236 225 237 = 0.2.0 = 226 238 Redesigned voting controls, improved tie handling in scoring, better export formatting, and various bug fixes. All times are now normalized to UTC.
Note: See TracChangeset
for help on using the changeset viewer.