211 Commits
Author SHA1 Message Date
Mathieu Moran 081d4fd5d4 Update README with project status and language support
Added a WIP note about project status and bugs.
2026-04-01 21:53:44 -04:00
Mathieu Moran 90e03fb4fc Revise README for language support and formatting
Updated the README to reflect changes in the application interface and fixed table formatting.
2026-02-01 23:24:50 -05:00
Mathieu Moran 901d171cba Update README with screenshot gallery and user name redaction 2026-02-01 23:19:27 -05:00
Mathieu Moran 0fcb5d702f Tweak light-mode navbar loading styles 2026-02-01 22:55:27 -05:00
Mathieu Moran 6016538b63 Update movies.html 2026-02-01 22:44:26 -05:00
Mathieu Moran f80f632bf5 Add blur line effect for navbar buttons during i18n loading 2026-02-01 22:41:50 -05:00
Mathieu Moran a524650b5e Fix Admin button active state and update Movies page title 2026-02-01 22:37:02 -05:00
Mathieu Moran d39e74ee9b Tweak navbar loading state and localize rating save 2026-02-01 22:23:38 -05:00
Mathieu Moran e8745ed88d Tweak navbar blur and admin i18n 2026-02-01 20:56:25 -05:00
Mathieu Moran e0886f2c8a Document localization and first-time admin setup 2026-02-01 20:29:14 -05:00
Mathieu Moran 4308d6babf Implement en/fr localization across admin and user UIs
Introduce client-side localization helper and en/fr locale bundles.

Route admin control, picks, movies, stats, winners and settings UIs through i18n keys instead of hardcoded text.

Remove French text from the English UI and standardize shared messages (saving, network errors, confirmations).

Update .gitignore to ignore editor metadata and localization helper artifacts.
2026-02-01 20:20:02 -05:00
Mathieu MoranandClaude Opus 4.5 dd313f8f5e Polish navbar and toggle styling
- Rename "Statistiques" to "Stats" in navbar across all pages
- Fix Admin button showing yellow border on click
- Fix toggle switch background to blend with dark mode container

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-01 10:16:05 -05:00
Mathieu MoranandClaude Opus 4.5 6186f3a051 Add movie runtime, bulk OMDb refresh, and first-time setup
- Add runtime field to Movie model and display formatted duration (e.g., 2h 22min) on movie cards
- Add bulk "Refresh OMDb" button in admin panel for selected movies
- Add runtime field to movie edit modal in admin panel
- Implement first-time setup flow: show admin creation form on login page when no admin exists
- Block registration page until initial setup is complete

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-02-01 10:08:15 -05:00
Mathieu MoranandClaude Opus 4.5 087f4f4023 Fix form-switch toggle visibility in dark mode
The toggle button for "Films non regardés seulement" was invisible
in dark mode. Added specific styles for the form-switch component
with proper background colors and slider visibility.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-31 22:54:39 -05:00
Mathieu MoranandClaude Opus 4.5 7a629b6c91 Fix cookie expiration, ranking by completion time, and checklist confirmation
Issues fixed:
- #87: Extend video session cookie to 24h and add keep-alive refresh every
  30 minutes during playback to prevent expiration while watching long movies
- #88: Rank checklist completers by completion datetime (earliest first)
  in both the 100% tab and main stats ranking as tiebreaker
- #89: Add confirmation modal when unchecking a movie from the checklist
  to prevent accidental removal

Co-Authored-By: Claude Opus 4.5 <[email protected]>
2026-01-31 22:42:13 -05:00
Mathieu Moran c81135a4c1 Polish admin and stats tab styling
Improve tab border handling and spacing for admin and stats views, plus minor copy spacing fixes.
2026-01-27 10:36:31 -05:00
Mathieu Moran 9868d2c39a Refine admin control layout and stats tabs
Tighten admin tab layout/styling and simplify control UI while aligning stats tab spacing and labels.
2026-01-27 02:45:49 -05:00
Mathieu Moran 0ae3af13f1 Adjust mobile navbar user layout
Center the username between the toggle and sign-out on mobile and remove hard name truncation.
2026-01-26 19:27:57 -05:00
Mathieu Moran cf57eee5a3 Trim long navbar usernames on mobile
Limit displayed user names to 15 characters with ellipsis to prevent layout wrap.
2026-01-26 19:00:27 -05:00
Mathieu Moran a17d548431 Fix README preview GIF path
Restore the relative image link so the README works across branches.
2026-01-26 18:48:42 -05:00
Mathieu Moran 341a5e6c61 Update README preview GIF 2026-01-26 18:41:37 -05:00
Mathieu Moran b75be09aec Merge pull request #81 from quoije/cursor/ui-nav-active-and-remove-titles
UI polish: remove page titles and highlight active nav
2026-01-26 18:15:58 -05:00
Mathieu Moran 36057d75df UI polish: remove page titles and highlight active nav 2026-01-26 18:15:30 -05:00
Mathieu Moran e8d838318e Merge pull request #80 from quoije/2026
Fix theme flash on player navigation
2026-01-26 02:48:34 -05:00
Mathieu Moran 1d41cd29d9 Fix theme flash on navigation
Persist last applied theme in session storage to avoid light-mode flash and toggle flicker when navigating between pages (notably player.html). Also revert README preview back to preview.png for now.
2026-01-26 02:46:35 -05:00
Mathieu Moran cde6971544 Merge pull request #79 from quoije/2026
theme rework
2026-01-26 02:07:41 -05:00
Mathieu Moran 8ee3c5aa5b Update README preview GIF
Switch README screenshot to animated GIF and replace static preview images with the animated asset.
2026-01-26 02:04:39 -05:00
Mathieu Moran 01c478aafe Theme rework: polish dark mode and previews
Refine mobile nav layout, improve dark theme UI polish, and update README previews with light/dark variants.
2026-01-26 01:47:17 -05:00
Mathieu Moran 67040095bc Merge pull request #78 from quoije/2026
Update README preview and features list
2026-01-25 18:46:47 -05:00
Mathieu Moran 6f66eef0d4 Update README preview and features list
Use a relative preview image path and align feature bullets with current app capabilities.
2026-01-25 18:44:56 -05:00
Mathieu Moran 6543ed172b Merge pull request #77 from quoije/2026
Add ratings and refine movies UI
2026-01-25 18:20:47 -05:00
Mathieu Moran c2f03230b2 Fix padding for 5 star rating
Keep rating layout stable when the average label is hidden.
2026-01-25 18:19:19 -05:00
Mathieu Moran 7ebd57931c Merge pull request #76 from quoije/2026
oops, fixed the embarrassing French label
2026-01-25 18:10:44 -05:00
Mathieu Moran a30bbcf514 oops, fixed the embarrassing French label
Corrected the movies page label to proper French.
2026-01-25 18:09:11 -05:00
Mathieu Moran 55a93a0ac5 Merge pull request #75 from quoije/2026
Allow clearing ratings and polish movies UI
2026-01-25 18:01:02 -05:00
Mathieu Moran 86a25b77e8 Allow clearing ratings and polish movies UI
Enable toggling ratings off by re-clicking the same star in the player and movie list, and tighten the movies card layout around rating and categories.
2026-01-25 17:58:50 -05:00
Mathieu Moran 8790f46a00 Merge pull request #74 from quoije/2026
Add user movie ratings and refresh movie cards
2026-01-25 17:14:07 -05:00
Mathieu Moran fe21a12c82 Add user movie ratings and refresh movie cards
Store per-user 5-star ratings with averages and surface them in the player. Refresh movie list layout to show ratings and details in a cleaner card layout.
2026-01-25 16:16:07 -05:00
Mathieu Moran 23ac8e07a3 Merge pull request #73 from quoije/2026
we subbing now bois
2026-01-25 02:02:20 -05:00
Mathieu Moran 406a4f0bb3 Support multiple subtitle tracks
Adds multi-language subtitle storage, upload/removal, and player/admin UI support, plus fixes subtitle metadata updates during upload.
2026-01-25 02:01:08 -05:00
Mathieu Moran 891eb67e2d Add subtitle UI badge and VTT positioning
Documents the disclaimer, shows subtitles in admin movie badges, and offsets VTT subtitles from the bottom.
2026-01-25 01:00:48 -05:00
Mathieu Moran a9592ece7a Add subtitle upload and streaming support
Adds movie subtitle metadata and admin upload flow with SRT to VTT conversion, plus player/control UI and API route updates for subtitle delivery.
2026-01-25 00:29:04 -05:00
Mathieu Moran 9076461c7e Ignore subtitles uploads directory
Prevents subtitle files from being added to the repository.
2026-01-25 00:27:46 -05:00
Mathieu Moran f9d8722b4e Add low-quality server video support
Include low-quality video file paths in movie settings, expose them through the admin UI, and add a player toggle that streams /api/video with quality=low when available. Extend both Node and Python video endpoints to serve the low-quality file.

Test: not run (mark for testing)
2026-01-24 09:26:23 -05:00
Mathieu Moran 1e16b964e6 Merge pull request #72 from quoije/2026
Promote 2026 updates to prod
2026-01-22 14:28:54 -05:00
Mathieu Moran b00280e9e4 Add CAM tagging to movies
Store a CAM flag in the movie model and expose it via the admin add/edit flow, then render a CAM badge on movie posters in the films page.
2026-01-22 14:26:43 -05:00
Mathieu Moran be47f3830d Merge pull request #71 from quoije/2026
Refactor control panel and add loading bar to Mes picks
2025-12-28 02:21:03 -05:00
Mathieu Moran d10164076c Refactor control panel: merge Lecteur tab into Visibilité and add loading bar to Mes picks
- Remove 'Lecteur' subtab from Réglages section
- Move player UI settings (show Source/Progress) into Visibilité tab
- Consolidate reload/save buttons to use single set for both visibility and player UI settings
- Simplify visibility tab formatting with minimalistic, consistent styling
- Add loading bar progress indicator to Mes picks tab matching movies/checklist/stats pages
- Update JavaScript to handle both visibility config and player UI settings together
- Improve code organization and user experience
2025-12-28 02:20:21 -05:00
Mathieu Moran b5827acfd1 Merge pull request #70 from quoije/2026
Remove unnecessary files and update .gitignore
2025-12-28 01:32:41 -05:00
Mathieu Moran 211c115d41 Remove unnecessary files and add OS/Python cache files to .gitignore
- Remove .DS_Store (macOS system file)
- Remove Python __pycache__ files (compiled bytecode)
- Add .DS_Store, Thumbs.db, desktop.ini to .gitignore
- Add Python cache patterns (__pycache__/, *.pyc, *.pyo, *.pyd) to .gitignore
2025-12-28 01:31:45 -05:00
Mathieu Moran 55fe658046 Merge pull request #69 from quoije/2026
Merge 2026 branch into prod
2025-12-28 01:09:12 -05:00
Mathieu Moran fb07c81a43 Add note that application interface is in French 2025-12-28 01:03:07 -05:00
Mathieu Moran b6df70864d Update README files with comprehensive feature documentation
- Main README.md:
  - Reorganized features into User Features and Admin Features sections
  - Added missing features: Oscar picks system, category management, winner management, view all picks, database backup/restore
  - Updated database models section to list all 6 models
  - Simplified Python video server section to reference detailed README
  - Kept preview image and existing structure

- python_video_server/README.md:
  - Enhanced Endpoints section with response codes and detailed descriptions
  - Added comprehensive Functions Reference section:
    - Public Endpoints: create_video_session, stream_video, healthz
    - Helper Functions: all 16 internal functions documented with descriptions
  - Verified all features, environment variables, and endpoints match actual code
  - No content removed, only additions for better documentation
2025-12-28 00:59:27 -05:00
Mathieu Moran ccf7340727 Remove unused uploads directory creation code 2025-12-28 00:50:14 -05:00
Mathieu Moran e8dabad3c2 Clean up: Add uploads, exemple, and OCR files to .gitignore and remove from repository 2025-12-28 00:50:04 -05:00
Mathieu Moran 6a2ec537e9 Fix category management and remove import feature
- Fix category editing: Added GET /api/categories/:categoryId endpoint to fetch single category by ID, fixing 'Category not found' error when editing categories for active year
- Fix picks handling: Filter out picks for deleted categories when submitting, loading, and calculating scores to prevent errors
- Fix scores display: Always calculate scores from pickDetails instead of stored score value to ensure accuracy when categories are removed
- Add picks cleanup: Automatically clean up picks for deleted categories in database when categories are deleted or scores are fetched
- Fix winner clearing: Allow clearing winners by accepting empty/null winnerName in PUT /api/categories/:categoryId/winner
- Enable category number editing: Made category number editable in edit modal with validation and conflict checking; automatically updates all related picks when category number changes
- Add bulk category management: Added bulk select and delete functionality in Liste tab with select all checkbox and bulk delete button
- Remove import feature: Completely removed PDF/image import functionality including OCR, file upload, and all related UI components
- Update dependencies: Removed unused packages (tesseract.js, sharp, pdfjs-dist, canvas) after removing import feature
- Improve file cleanup: Added retry logic for file deletion to handle Windows file locking issues
2025-12-28 00:48:48 -05:00
Mathieu Moran 7d8077774b Add unsaved changes tracking with banner and button state management in picks tab 2025-12-27 23:38:23 -05:00
Mathieu Moran dc726b9b71 Remove video files and update file structure 2025-12-27 23:22:55 -05:00
Mathieu Moran b39849f745 Filter out scores for removed users in Scores tab (prevent Unknown user display) 2025-12-27 23:21:32 -05:00
Mathieu Moran 9979682beb Add .gitignore rule to ignore files in public/video/ 2025-12-27 23:16:47 -05:00
Mathieu Moran bf45de3bbc Make header logo clickable to navigate to movies page on all pages 2025-12-27 23:07:05 -05:00
Mathieu Moran 70b309d1ae Add visibility toggle for picks features and rename Mes choix to Mes picks
- Renamed 'Mes choix' to 'Mes picks' across all pages and JavaScript files
- Added visibility toggle in control panel (Réglages > Visibilité) to control:
  - Display of 'Mes picks' button in navigation
  - Display of 'Bon picks' column in user statistics
- Combined both visibility options into single toggle for simplicity
- Added backend API endpoints for visibility configuration
- Implemented localStorage caching for instant visibility updates
- Fixed flash of content issues when navigating between pages
- Updated stats endpoint to calculate correct picks on-the-fly from current winners
- Renamed 'Bonnes réponses' to 'Bon picks' in user statistics table
2025-12-27 22:03:01 -05:00
quoije 1281d77c4e WIP: Add points system for movies and picks, admin configuration, and stats page updates 2025-12-26 12:37:30 -05:00
Mathieu Moran 38b4d03c4e Merge pull request #68 from quoije/2026
update preview and fix image url
2025-12-22 23:33:50 -05:00
Mathieu Moran 7722ac89c3 update preview and fix image url 2025-12-22 23:33:00 -05:00
Mathieu Moran 1c4b18f8aa fix logo (#65) 2025-12-22 21:22:28 -05:00
Mathieu Moran b2da6de9b3 fix logo 2025-12-22 21:22:05 -05:00
Mathieu Moran ae5a5ee073 Merge pull request #64 from quoije/2026
Movie watch progression tracking (#63)
2025-12-22 19:59:07 -05:00
Mathieu MoranandCursor Agent e0d5531041 Movie watch progression tracking (#63)
* feat: Add movie playback progress display and API

Co-authored-by: mathieumoran1 <[email protected]>

* Add padding to bottom of movies page

Co-authored-by: mathieumoran1 <[email protected]>

* Fix: Hide resume/progress bar when near end of video

Co-authored-by: mathieumoran1 <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 19:58:37 -05:00
Mathieu Moran 0089d2f376 Merge pull request #61 from quoije/2026
v2.3 - redesign admin panel, redesign stats, fix cache and more
2025-12-22 18:17:47 -05:00
Mathieu MoranandCursor Agent 79b9f4a5a3 Statistics page redesign (#60)
* feat: Add modal to display watched movies with details

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor movie modal: remove search, improve category display

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor movie category display to use details element

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor movie playback and add clickability to stats

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor: Improve movie linking and player logic

Co-authored-by: mathieumoran1 <[email protected]>

* Prevent caching of sensitive and dynamic API data

Co-authored-by: mathieumoran1 <[email protected]>

* feat: Prevent caching of sensitive admin data

Co-authored-by: mathieumoran1 <[email protected]>

* feat: Add no-cache headers to API requests

Co-authored-by: mathieumoran1 <[email protected]>

* Remove unused movie sorting functionality

The movie sorting dropdown and its associated logic were removed as they are no longer needed.

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor admin settings to use tabs instead of accordion

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor: Adjust button layout for active year selection

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor active year selection UI

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor: Improve admin settings layout for better responsiveness

Co-authored-by: mathieumoran1 <[email protected]>

* Remove redundant completion explanation from user-stat.html

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor user stats page to display year in ranking tab badge

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor: Rename 'Finisseur(s)' column to 'Nombre'

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor: Remove redundant completer count column

Co-authored-by: mathieumoran1 <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 18:16:37 -05:00
Mathieu MoranandCursor Agent 82f9462dda Admin movies tab redesign (#59)
* Refactor admin movie tab into sub-tabs for manage, add, and help

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor admin version management UI and styling

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor: Adjust footer padding and border-radius

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor: Improve admin subtab styling

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor admin backup tab into subtabs for backup, restore, and help

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor admin winners tab UI and functionality

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor: Display all winners by year, not just current year

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor winners tab to simplify management

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor control.html layout for winner section

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor 100% completion modal UI with tabs and preview

Co-authored-by: mathieumoran1 <[email protected]>

* Remove redundant 'Preview' and 'Modal' badges

Co-authored-by: mathieumoran1 <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 17:15:46 -05:00
Mathieu Moran 0b1709c2ed Merge pull request #58 from quoije/2026
v2.2 - redesign admin panel, fix readme.md
2025-12-22 16:36:48 -05:00
Mathieu MoranandCursor Agent 647a0b6a47 Readme file updates (#57)
* Refactor READMEs and clarify optional features

Co-authored-by: mathieumoran1 <[email protected]>

* Add video auth details and Let's Encrypt helper

Co-authored-by: mathieumoran1 <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 16:32:17 -05:00
Mathieu MoranandCursor Agent eeaf2592ea Docs: Add more .env options and python server details (#56)
Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 16:23:28 -05:00
Mathieu MoranandCursor Agent 59eb70dea9 Player text visibility option (#55)
* feat: Add player admin UI settings

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor player UI settings to combine source and progress

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor admin settings to use accordion layout

Co-authored-by: mathieumoran1 <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 16:17:31 -05:00
Mathieu Moran 57918f8b88 Fix image link in README.md
Updated image link in README for Oscars Pool 2026.
2025-12-22 05:43:02 -05:00
Mathieu Moran 2da9e44105 Merge pull request #54 from quoije/2026-video.backend
add video backend server, fix styling and more
2025-12-22 05:42:04 -05:00
Mathieu Moran 8c4941b0a2 Update README.md 2025-12-22 05:40:50 -05:00
Mathieu Moran 9d3376bbb0 update preview v2.1 2025-12-22 05:39:25 -05:00
Mathieu MoranandCursor Agent 421206d280 Checklist styling update (#53)
* Refactor checklist styling and structure

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor movie display and styling for better UI

Co-authored-by: mathieumoran1 <[email protected]>

* feat: Add page loader component and integrate into player

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor: Adjust IMDB icon size and alignment

Co-authored-by: mathieumoran1 <[email protected]>

* Feat: Show admin status on player page

Co-authored-by: mathieumoran1 <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 05:36:33 -05:00
Mathieu MoranandCursor Agent 1a460ca06c Fix: Prevent early client progress saves from overwriting server progress (#52)
Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 05:08:53 -05:00
Mathieu MoranandCursor Agent a75a024249 Fix: Improve playback progress saving and retrieval (#51)
Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 04:54:03 -05:00
Mathieu MoranandCursor Agent d933d69b5f feat: Add IMDb ID to playback progress and auto-mark as watched (#50)
Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 04:39:52 -05:00
Mathieu MoranandCursor Agent 5325576dc4 Dns validation for https (#49)
* Add DNS-01 challenge support for Let's Encrypt

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor letsencrypt script to use /dev/tty for interactive prompts

Co-authored-by: mathieumoran1 <[email protected]>

* feat: Add optional DNS hook mode for certonly

Co-authored-by: mathieumoran1 <[email protected]>

* Refactor: Improve MongoDB connection and add certifi

Co-authored-by: mathieumoran1 <[email protected]>

* Checkpoint before follow-up message

Co-authored-by: mathieumoran1 <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 04:16:45 -05:00
Mathieu MoranandCursor Agent 812590d262 Add Python-only Let's Encrypt certificate management (#48)
Co-authored-by: Cursor Agent <[email protected]>
2025-12-22 03:20:41 -05:00
Mathieu Moran 1b27b378ec Merge pull request #47 from quoije/cursor/page-title-and-player-ui-a564
Page title and player UI
2025-12-22 03:06:19 -05:00
Cursor Agentandmathieumoran1 20d0a52f78 Refactor: Organize movie edit form into tabs
Separate movie info and video player settings into distinct tabs for better organization and user experience.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 07:40:03 +00:00
Cursor Agentandmathieumoran1 dfd8e9b4fd Fix: Adjust log off button size
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 07:37:42 +00:00
Cursor Agentandmathieumoran1 0c488319f6 Refactor: Rename "Oscar Pool" to "Pool Oscars" in titles
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 07:33:32 +00:00
Cursor Agentandmathieumoran1 76e9a934d6 Refactor: Update brand name and dynamic titles
Consistently use "Oscar Pool" as the brand name. Dynamically update page titles to include the active Oscar year.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 07:31:26 +00:00
Mathieu Moran 88129ca5ac Merge pull request #46 from quoije/cursor/webplayer-bug-fixes-aea1
Webplayer bug fixes
2025-12-22 02:20:31 -05:00
Cursor Agentandmathieumoran1 e91c919b46 Refactor: Use query token for API video playback
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 07:11:03 +00:00
Cursor Agentandmathieumoran1 fd881399cd Add caching and token fallback for /api/video streams
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 07:02:32 +00:00
Cursor Agentandmathieumoran1 8d93c31df8 Refactor video progress tracking UI and logic
Improve progress UI, handle token expiration, and fix restore logic.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 06:57:58 +00:00
Mathieu Moran f65d92743e Merge pull request #45 from quoije/cursor/video-server-and-timestamp-fc01
Video server and timestamp
2025-12-22 01:50:21 -05:00
Cursor Agentandmathieumoran1 1cda3c23e1 Refactor video progress saving logic
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 06:43:29 +00:00
Cursor Agentandmathieumoran1 ce234d0fac feat: Add video playback progress display and saving
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 06:40:18 +00:00
Cursor Agentandmathieumoran1 8590c30596 Add support for App/Python protected video streams
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 06:33:45 +00:00
Cursor Agentandmathieumoran1 53eb7faf06 Add fallback for MONGO_DB_NAME from MONGO_URI
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 06:00:31 +00:00
Cursor Agentandmathieumoran1 60fd5a25ce Refactor: Read environment variables at runtime
This change ensures that environment variables are read at runtime, which is important for worker reloads on Windows.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 05:57:35 +00:00
Cursor Agentandmathieumoran1 2025f0231c Add movie ID display and copy functionality
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 05:38:42 +00:00
Cursor Agentandmathieumoran1 ea37bf95ef Refactor video session handling and CORS configuration
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 05:35:50 +00:00
Cursor Agentandmathieumoran1 e6537884a3 feat: Add Python video server and improve playback
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 05:25:19 +00:00
Mathieu Moran 837e02ef4c Merge pull request #44 from quoije/cursor/video-server-and-embed-7ca8
Video server and embed
2025-12-22 00:13:52 -05:00
Cursor Agentandmathieumoran1 2f767e15a6 feat: Add server-hosted video streaming support
Introduces a new /api/video route for streaming video files stored on the server. This allows for direct playback of local video files with seeking capabilities. The Movie model and UI have been updated to support this new feature.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-22 05:12:06 +00:00
Mathieu Moran f54b1d91c5 Merge pull request #43 from quoije/cursor/header-loading-optimization-321c
Header loading optimization
2025-12-21 18:03:00 -05:00
Cursor Agentandmathieumoran1 e6f3b02830 Refactor: Improve header loading and image rendering
Add early JS to populate header elements and optimize image loading.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 23:02:22 +00:00
Mathieu Moran 3b055bfdeb Merge pull request #42 from quoije/cursor/admin-panel-version-styling-4134
Admin panel version styling
2025-12-21 17:21:25 -05:00
Cursor Agentandmathieumoran1 1f3511b455 Refactor version history display and styling
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 22:21:03 +00:00
Mathieu Moran c79b3f1e16 Merge pull request #41 from quoije/cursor/admin-panel-version-control-67d5
Admin panel version control
2025-12-21 17:16:01 -05:00
Cursor Agentandmathieumoran1 f5bd8709a0 feat: Manage app version from admin panel
Removes GitHub dependency for app version display.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 22:15:15 +00:00
Mathieu Moran ad211b11bb Merge pull request #40 from quoije/cursor/blur-below-header-b6ef
Blur below header
2025-12-21 17:05:37 -05:00
Cursor Agentandmathieumoran1 1bc65ebd77 Refactor page loader to use a dedicated JS class
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 22:04:58 +00:00
Mathieu Moran 6d7269f331 Merge pull request #39 from quoije/cursor/blur-effect-timing-6b09
Blur effect timing
2025-12-21 16:59:25 -05:00
Cursor Agentandmathieumoran1 d9ed2cdc92 Refactor: Preload page loader and use DOMContentLoaded
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 21:59:02 +00:00
Mathieu Moran 282bc1750c Merge pull request #38 from quoije/cursor/content-blur-loading-scope-8828
Content blur loading scope
2025-12-21 16:55:12 -05:00
Cursor Agentandmathieumoran1 00f5328c75 Refactor: Simplify page loader UI and error handling
Remove subtitle and retry button from page loader. Update CSS for new loader style.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 21:54:59 +00:00
Cursor Agentandmathieumoran1 d683f04305 Fix page loader alignment with dynamic navbar
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 21:53:19 +00:00
Mathieu Moran 74c833b62c Merge pull request #37 from quoije/cursor/page-load-indicators-ea0b
Page load indicators
2025-12-21 16:50:46 -05:00
Cursor Agentandmathieumoran1 fa0ba74682 feat: Implement full-page loader with progress bar
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 21:50:24 +00:00
Mathieu Moran c7de8b8db0 Merge pull request #36 from quoije/cursor/films-checklist-loading-indicator-fa57
Films checklist loading indicator
2025-12-21 16:45:26 -05:00
Cursor Agentandmathieumoran1 377c5a828e Refactor loading indicators to use CSS dots
This change replaces inline spinners with a more subtle CSS-based loading indicator. It also cleans up the inline loading text to be more concise.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 21:44:49 +00:00
Mathieu Moran 4809c8ad85 Merge pull request #35 from quoije/cursor/films-and-checklist-loading-4f51
Films and checklist loading
2025-12-21 16:40:55 -05:00
Cursor Agentandmathieumoran1 34852ce931 Refactor: Fetch movie summary data separately
This change fetches movie counts and watched status in a separate, faster API call. This allows the UI to update header information (like movie counts and last updated time) much sooner, improving perceived performance. The `watchedMovies` array is now fetched as part of this summary endpoint.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 21:40:16 +00:00
Mathieu Moran b764d26ad0 Merge pull request #34 from quoije/cursor/loading-spinner-implementation-907f
Loading spinner implementation
2025-12-21 16:30:54 -05:00
Cursor Agentandmathieumoran1 762f75ad62 Add loading indicators to user statistics page
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 21:30:29 +00:00
Cursor Agentandmathieumoran1 e318e25ed1 Add loading and error states for movie lists
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 21:29:21 +00:00
Mathieu Moran aeff3504f0 Merge pull request #33 from quoije/cursor/films-checklist-page-performance-ddde
Films checklist page performance
2025-12-21 16:25:07 -05:00
Cursor Agentandmathieumoran1 3884ce6492 feat: Add compression and caching to movie routes
Adds gzip compression middleware and an in-memory cache for movie data to improve performance. Also adds database indexes for common query patterns.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 21:24:27 +00:00
Mathieu Moran 1e7ab962d6 Merge pull request #32 from quoije/cursor/oscar-date-configuration-cef0
Oscar date configuration
2025-12-21 16:04:33 -05:00
Cursor Agentandmathieumoran1 e326ead817 feat: Allow configuring Oscar date per year
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 21:03:55 +00:00
Mathieu Moran d732e1a013 Merge pull request #31 from quoije/cursor/statistique-page-cleanup-3226
Statistique page cleanup
2025-12-21 15:54:16 -05:00
Cursor Agentandmathieumoran1 05a4b9a673 Refactor user stats display to use tables
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 20:53:52 +00:00
Mathieu Moran 3c2ddf66ff Merge pull request #30 from quoije/cursor/statistics-page-tabs-4b57
Statistics page tabs
2025-12-21 15:49:43 -05:00
Cursor Agentandmathieumoran1 122c7ee8d3 Feat: Add tie indicator to user stats and app control
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 20:49:16 +00:00
Cursor Agentandmathieumoran1 52eaee65f6 Refactor: Support multiple winners per year and improve UI
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 20:48:29 +00:00
Cursor Agentandmathieumoran1 e6f3998d88 Refactor user stats page to use tabs and cache data
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 20:44:14 +00:00
Mathieu Moran a7b7df8689 Merge pull request #29 from quoije/cursor/yearly-completion-and-winner-stats-2e76
Yearly completion and winner stats
2025-12-21 15:41:04 -05:00
Cursor Agentandmathieumoran1 9a7063b9dc feat: Add winners tab and 100% completion stats
Adds a new section to the admin control panel for managing yearly winners.
Also includes a new section on the user statistics page to display users
who have completed 100% of movies for each year.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 20:38:01 +00:00
Mathieu Moran 5a2b40357e Merge pull request #28 from quoije/cursor/admin-control-tab-order-0fa0
Admin control tab order
2025-12-21 15:27:01 -05:00
Cursor Agentandmathieumoran1 58d5c3bd40 Refactor: Swap tab IDs and labels for clarity
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 20:26:30 +00:00
Mathieu Moran 595319945d Merge pull request #27 from quoije/cursor/security-audit-and-readme-0bd2
Security audit and readme
2025-12-21 15:21:12 -05:00
Cursor Agentandmathieumoran1 20ff670ae2 Remove outdated multer installation instruction
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 20:20:07 +00:00
Cursor Agentandmathieumoran1 7f0b7834e6 feat: Add multer to admin DB backup/restore and improve validation
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 20:19:00 +00:00
Mathieu Moran ee483e342c Merge pull request #26 from quoije/cursor/mongo-database-backup-restore-18d0
Mongo database backup restore
2025-12-21 15:11:51 -05:00
Cursor Agentandmathieumoran1 448c28caee feat: Add admin DB backup and restore functionality
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 20:08:58 +00:00
Mathieu Moran beaeaf57dd Merge pull request #25 from quoije/cursor/user-creation-control-page-ccb0
User creation control page
2025-12-21 15:02:38 -05:00
Cursor Agentandmathieumoran1 1b7a7f87a6 Refactor: Move add user form to a modal
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 20:01:43 +00:00
Cursor Agentandmathieumoran1 87396f72b8 feat: Add user creation form and API endpoint
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 20:00:13 +00:00
Mathieu Moran 64605cf24d Merge pull request #24 from quoije/cursor/optional-env-var-handling-ebfa
Optional env var handling
2025-12-21 14:56:18 -05:00
Cursor Agentandmathieumoran1 d87dff1c9d Refactor user management UI and add delete functionality
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 19:55:23 +00:00
Cursor Agentandmathieumoran1 9e825eb1a6 Refactor version endpoint to improve error handling and caching
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 19:52:08 +00:00
Cursor Agentandmathieumoran1 d0edfe9278 Refactor OMDb API handling and user registration verification
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 19:41:58 +00:00
Mathieu Moran 4dced844e4 Merge pull request #23 from quoije/cursor/readme-env-file-setup-eda9
Readme env file setup
2025-12-21 14:33:37 -05:00
Cursor Agentandmathieumoran1 12ea9ef160 Add comments to .env file for clarity
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 19:32:59 +00:00
Cursor Agentandmathieumoran1 4fe8ad84ab Refactor: Improve JWT secret generation instructions
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 19:31:54 +00:00
Cursor Agentandmathieumoran1 1f98a1a24a Refactor: Improve README env variable documentation
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 19:30:42 +00:00
Cursor Agentandmathieumoran1 afb158beca Fix: Remove redundant env file instruction
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 19:29:36 +00:00
Cursor Agentandmathieumoran1 253ac63fe9 Refactor: Improve README setup instructions
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 19:28:13 +00:00
Mathieu Moran 0fe87e1971 Merge pull request #22 from quoije/cursor/readme-mongodb-info-db57
Readme mongodb info
2025-12-21 14:26:18 -05:00
Cursor Agentandmathieumoran1 b2882e01d2 Add MongoDB database section to README
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 19:25:48 +00:00
Mathieu Moran 59d382cf37 Merge pull request #21 from quoije/cursor/readme-features-list-9898
Readme features list
2025-12-21 14:23:20 -05:00
Cursor Agentandmathieumoran1 f602808ef6 Add detailed features list to README
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 19:22:23 +00:00
Mathieu Moran 6532ca85f8 Merge pull request #20 from quoije/cursor/movie-year-selection-control-a2b6
Movie year selection control
2025-12-21 14:17:10 -05:00
Cursor Agentandmathieumoran1 19e5f1d522 Refactor: Use select for active year and clean year data
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 19:16:13 +00:00
Mathieu Moran 9234c2ed1e Update README.md 2025-12-21 13:58:03 -05:00
Mathieu Moran b2260f6028 Update README.md 2025-12-21 13:52:18 -05:00
Mathieu Moran 0c5ac630e1 update preview v2 2025-12-21 13:46:11 -05:00
Mathieu Moran dbaed3a629 update preview 2025-12-21 13:42:07 -05:00
Mathieu Moran 393f942d19 Merge pull request #18 from quoije/cursor/web-player-playback-progress-b479
Web player playback progress
2025-12-20 22:23:26 -05:00
Cursor Agentandmathieumoran1 1f2462b01e feat: Add movie playback progress tracking
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 03:22:41 +00:00
Mathieu Moran 206ca2e9d7 Merge pull request #16 from quoije/cursor/player-page-styling-and-links-1590
Player page styling and links
2025-12-20 19:51:21 -05:00
Cursor Agentandmathieumoran1 b72f1bde82 Refactor movie player and navigation logic
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 00:50:45 +00:00
Mathieu Moran 7a7d94968b Merge pull request #15 from quoije/cursor/movie-player-integration-1f3a
Movie player integration
2025-12-20 19:28:53 -05:00
Cursor Agentandmathieumoran1 5cfa35c79c Add player column and badges to movie management table
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 00:27:58 +00:00
Cursor Agentandmathieumoran1 43925c3b60 Refactor: Add flexible video source options to Movie model
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 00:26:34 +00:00
Cursor Agentandmathieumoran1 ae1f065000 feat: Add movie player page and logic
Implement the movie player page and associated JavaScript logic for handling various video sources and embed types.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 00:24:01 +00:00
Cursor Agentandmathieumoran1 2a74ed38de Add movie player link and API endpoint
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-21 00:20:37 +00:00
Mathieu Moran e3baaf0fc7 Merge pull request #14 from quoije/cursor/user-list-on-control-page-19f4
User list on control page
2025-12-20 19:04:41 -05:00
Mathieu Moran 665cca7164 fix logos, remove year 2025-12-20 19:02:04 -05:00
Cursor Agentandmathieumoran1 f073f81c57 Add admin user list functionality
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 23:52:48 +00:00
Mathieu Moran 975e922c40 Merge pull request #13 from quoije/cursor/checklist-modal-content-settings-b48f
Checklist modal content settings
2025-12-20 18:40:19 -05:00
Cursor Agentandmathieumoran1 4aa969414d feat: Add 100% completion modal settings
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 23:39:26 +00:00
Mathieu Moran 1389b004f6 Merge pull request #12 from quoije/cursor/completion-modal-content-settings-4bef
Completion modal content settings
2025-12-20 18:37:15 -05:00
Cursor Agentandmathieumoran1 a1c781e8a5 feat: Add customizable completion modal for checklist
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 23:36:12 +00:00
Mathieu Moran ef97ff0730 Merge pull request #11 from quoije/cursor/control-page-layout-adjustments-bcf8
Control page layout adjustments
2025-12-20 18:29:18 -05:00
Cursor Agentandmathieumoran1 232f88d61a Refactor: Improve admin page layout and responsiveness
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 23:28:42 +00:00
Mathieu Moran 35da8274b0 Merge pull request #10 from quoije/cursor/admin-panel-layout-redesign-7a39
Admin panel layout redesign
2025-12-20 18:24:10 -05:00
Cursor Agentandmathieumoran1 bc017088f7 Refactor admin page into tabbed interface
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 23:21:35 +00:00
Mathieu Moran ff289c38da Merge pull request #9 from quoije/cursor/user-password-reset-feature-23c0
User password reset feature
2025-12-20 18:15:13 -05:00
Cursor Agentandmathieumoran1 c83b4685a2 feat: Implement password reset and change functionality
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 23:13:51 +00:00
Mathieu Moran 7a5ef7e9a0 Merge pull request #8 from quoije/cursor/movie-update-frontend-issue-06ef
Movie update frontend issue
2025-12-20 18:02:30 -05:00
Cursor Agentandmathieumoran1 6cb503fe35 feat: Show last updated movies per year
Add API and UI to display the last updated movies for the active year.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 23:01:24 +00:00
Mathieu Moran 03a4b4e5dc Merge pull request #7 from quoije/cursor/global-movie-year-setting-3e2d
Global movie year setting
2025-12-20 17:48:44 -05:00
Cursor Agentandmathieumoran1 7ff02207f6 feat: Implement dynamic Oscar year selection
This commit introduces the ability to select and manage the active Oscar year. This change affects page titles, API requests, and data filtering across the application, ensuring that all views and statistics are relevant to the chosen year.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 22:48:11 +00:00
Mathieu Moran dda483b757 Merge pull request #6 from quoije/cursor/global-movie-year-setting-9879
Global movie year setting
2025-12-20 17:36:34 -05:00
Cursor Agentandmathieumoran1 64cfdc3c6d Add settings API for managing active Oscar year
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 22:34:44 +00:00
Mathieu Moran 7c20a2b2ae Merge pull request #5 from quoije/cursor/admin-movie-editing-feature-b726
Admin movie editing feature
2025-12-20 17:26:02 -05:00
Cursor Agentandmathieumoran1 e0ec4760ce feat: Add movie editing functionality and modal
This commit introduces the ability to edit movie details through a modal interface. It includes backend API endpoints for updating movie information and frontend logic to display and manage the edit modal. The changes also refine the year selection and movie loading processes in the admin panel.

Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 22:24:38 +00:00
Mathieu Moran 9ac9b1bd04 Update README.md 2025-12-20 17:17:22 -05:00
Mathieu Moran 9854ec30f5 Update README.md 2025-12-20 17:17:05 -05:00
Mathieu Moran 1b9174bf0d Merge pull request #4 from quoije/cursor/movie-categorization-and-stats-4456
Movie categorization and stats
2025-12-20 17:16:18 -05:00
Cursor Agentandmathieumoran1 eeb337fe08 feat: Add Oscar year to movies and admin management
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 22:14:47 +00:00
Mathieu Moran da9d746928 Merge pull request #2 from quoije/cursor/movie-last-update-time-6461
Movie last update time
2025-12-20 16:32:22 -05:00
Cursor Agentandmathieumoran1 0d35a19cc5 Add endpoint to get last movie update timestamp
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 21:31:35 +00:00
Cursor Agentandmathieumoran1 5c4366a9e7 Add timestamps to Movie model
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 21:30:09 +00:00
Mathieu Moran eaf7904940 Merge pull request #1 from quoije/cursor/oscar-2026-movie-control-147b
Oscar 2026 movie control
2025-12-20 16:23:05 -05:00
Cursor Agentandmathieumoran1 f07547cc59 Update Oscars year to 2026 and add admin page
Co-authored-by: mathieumoran1 <[email protected]>
2025-12-20 21:20:05 +00:00
Mathieu Moran 6108c8a40f cringe 2025-09-26 22:13:14 -04:00
quoije 6110877ba3 creed 2025-03-16 14:18:38 -04:00
60 changed files with 21066 additions and 538 deletions
+51
View File
@@ -137,3 +137,54 @@ dist
# ayylmao # ayylmao
ayylmao.js ayylmao.js
# Python video server - generated TLS material (certbot state)
python_video_server/certs/
# Backup files
backups/
# Video files in public/video/
public/video/*
# Subtitles
subtitles/*
# Uploads directory (temporary files)
uploads/
# Example files
exemple/
# OCR training data
*.traineddata
eng.traineddata
# OS and editor files
.DS_Store
Thumbs.db
desktop.ini
# VS Code project settings
.vscode/
# AI assistant/editor metadata
.claude/
.cursor/
# Python cache files
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
# Temporary scripts
temp/
# Localization helper artifacts (not part of app)
BEFORE_AFTER_COMPARISON.md
LOCALIZATION_COMPLETE.md
LOCALIZATION_PROGRESS.md
VERIFICATION_REPORT.md
localize_remaining.py
+167 -12
View File
@@ -1,18 +1,173 @@
## Oscars Pool 2025 ## Oscars Pool 2026
Little nodejs + mongodb app that I hack together with ChatGPT to keep track of watched movies for an incoming Oscars Pool with some friends. App is not secure, using old html standard and is not the most responsive but does the job. Little nodejs + mongodb app that I hack together with ChatGPT and Cursor to keep track of watched movies for an incoming Oscars Pool with some friends.
Would probably need to revamp everything, but didn't want to spend more time on it. **Note:**
**WIP** This project still has bugs, some visual glitches and need some work.
![Oscars Pool 2025](https://github.com/quoije/oscar-pool/blob/prod/img/preview.png) The application interface now supports **English and French** via JSON locale bundles (default is English; the admin can force the site language from the Settings → Language tab).
**Disclaimer:** This was a fun, experimental project built with “AI slop” (Cursor, Claude and Copilot), so it may have security issues. For example, `python_video_server` uses the default token‑in‑URL approach to serve video files, which can leave user tokens in logs or browser history. There are ways to improve this, but at this point I’m blinder than Ray Charles.
| ![Movies](img/preview_movies.png) | ![Checklist](img/preview_checklist.png) |
| --- | --- |
| ![Statistics](img/preview_stats.png) | ![Admin](img/preview_admin.png) |
## Features
### User Features
- **User accounts**: register, login, JWT authentication
- **Dark mode**: follows system preference by default, with a user toggle (persisted per user)
- **Mobile-friendly UI**: improved responsive navbar + controls for small screens
- **Optional registration verification**: via `DOG_NAMES` environment variable (if unset, verification is skipped)
- **Multi-year support**: browse movies and stats by Oscar year (admin controls the active year)
- **Checklist + progress**: mark movies as watched (with date), progress bar, countdown to Oscars date
- **Movie details**: pulled from OMDb (title/plot/rating/poster) via IMDb ID (optional)
- **Movie ratings**: 1–5 star user ratings + global average
- **User statistics**: watched count, points from movies watched and Oscar picks, leaderboard
- **Video player**: direct video URL, server file (HD/low), embed, or legacy VOD link
- **Subtitles**: per-movie subtitle tracks in the player (VTT/SRT)
- **Resume playback**: per-user playback progress saving (time/duration)
- **Oscar picks**: make picks for each category, auto-save on selection
- **Scores**: view leaderboard with correct/incorrect pick counts, detailed breakdown per user
### Admin Features
- **Movie management**: add, edit (optionally refresh from OMDb), delete movies, search/bulk delete
- **Player source management**: configure video source, server files, low-quality fallback, subtitles
- **User management**: view/create/delete users, reset passwords (temp password + forced change on next login)
- **Oscar category management**: create, edit, delete categories with nominees, bulk delete
- **Winner management**: mark winners per category and overall winners by year (ties + optional points)
- **View all picks**: see everyone's picks in a comparison view
- **Global settings**: active Oscar year, Oscars date for countdown, points config, picks visibility
- **Completion modal**: customize 100% completion reward (title/text/HTML/video)
- **Database backup/restore**: create, download, and restore backups
- **App version control**: manage app version display (footer) from admin panel (no GitHub dependency)
- **Player UI settings**: control player page admin status display
## Localization (i18n)
The app ships with a lightweight client-side localization system and two locales: **English** and **French**.
- Locale files live in:
- `public/locales/en.json`
- `public/locales/fr.json`
- The i18n runtime is implemented in `public/js/localization.js` and exposed as `window.i18n`.
- The admin can choose the **site language** (currently `en` or `fr`) from the **Admin → Settings → Language** tab. This value is stored in MongoDB and used by the client to pick the active locale.
- Markup is localized via `data-i18n` attributes (and friends):
- `data-i18n` → element text content
- `data-i18n-placeholder` → input placeholders
- `data-i18n-aria-label` → ARIA labels
- `data-i18n-title` → `title` attributes
- JavaScript code uses a small helper to translate keys with an English fallback, for example:
```js
// inside public/js/* files
function t(key, fallback, params) {
if (window.i18n && typeof window.i18n.t === 'function') {
const base = window.i18n.t(key, params || {});
return base && base !== key ? base : (fallback || key);
}
return fallback || key;
}
showResponse('success', t('admin.movies.updatedSuccessfully', 'Movie updated successfully.'));
```
To add new text:
1. Add an English entry in `public/locales/en.json` (this is the base source of truth).
2. Optionally add the corresponding French translation to `public/locales/fr.json` using the same key.
3. In HTML, wire it with `data-i18n="your.key"`, or in JS call `t('your.key', 'English fallback')`.
## Setup ## Setup
Config those in your .env file: Create a `.env` file at the project root (same folder as `package.json`).
**DOG_NAMES=**`woof, WOOF, Woof` &nbsp;&nbsp;&nbsp;&nbsp;(for "verification")<br> Minimal steps:
**GITHUB_OWNER=**`quoije` &nbsp;&nbsp;&nbsp;&nbsp;(for version history)<br>
**GITHUB_REPO=**`oscars-pool` &nbsp;&nbsp;&nbsp;&nbsp;(for version history)<br> ```bash
**JWT_SECRET=**`RANDOM STRING` &nbsp;&nbsp;&nbsp;&nbsp;(for authentication token)<br> npm install
**MONGO_URI=**`mongodb+srv://USER:[email protected]/?retryWrites=true&w=majority&appName=Cluster69`<br> node index.js
**OMDB_API=**`1234567` &nbsp;&nbsp;&nbsp;&nbsp;(for movie info) ```
Add this to your `.env` (**required**: `MONGO_URI`, `JWT_SECRET`):
```bash
# required
MONGO_URI=mongodb://127.0.0.1:27017/oscars-pool # MongoDB connection string
JWT_SECRET=replace-with-a-random-string # JWT signing secret (auth)
# optional
DOG_NAMES=woof,WOOF,Woof # registration verification answers (comma-separated)
OMDB_API=your_omdb_key # OMDb API key (movie info)
```
Generate a good `JWT_SECRET`:
```bash
openssl rand -hex 32
```
## First-time admin setup
On a brand new database (no users with `role: 69`), the app exposes a simple first-time setup flow to create the initial admin account.
1. **Start the app**
- Make sure `.env` has at least `MONGO_URI` and `JWT_SECRET`.
- Run:
```bash
npm install
node index.js
```
2. **Open the home page**
- Visit `http://localhost:5050/` (or `http://localhost:${PORT}/` if you changed the port).
- If no admin user exists yet, you'll see an **admin setup card** instead of the regular login form.
- Fill in **name**, **email**, and **password**, then submit to create the first admin. The backend will:
- create a `User` document with `role: 69` (admin), and
- return a JWT so you're logged in immediately and redirected into the app.
3. **Access the admin panel**
- Once logged in as admin, open the **Admin / Control** page (or navigate to `/control.html`).
- From there you can:
- Set the **active Oscar year** (Settings → Active year).
- Configure the **Oscar date** for the countdown.
- Adjust **points configuration** (points per movie / per correct pick).
- Control **picks and version visibility**.
- Choose the **site language** (Settings → Language → `en` / `fr`).
- Manage movies, categories, winners, users, and the 100% completion modal.
After the first admin is created, the setup card disappears and the index page behaves as a normal login screen.
## Database (MongoDB)
This app uses **MongoDB** via Mongoose. Provide a connection string in `MONGO_URI`.
- Use **MongoDB Atlas** (recommended) or a local MongoDB instance.
- `MONGO_URI` examples:
- Atlas: `mongodb+srv://USER:PASS@cluster0.XXXX.mongodb.net/<dbName>?retryWrites=true&w=majority`
- Local: `mongodb://127.0.0.1:27017/<dbName>`
- Collections are created/managed automatically from the models in `models/`:
- `User` - user accounts
- `Movie` - Oscar-nominated movies
- `OscarCategory` - Oscar categories with nominees
- `OscarPick` - user picks for each category
- `Setting` - global settings (active year, etc.)
- `PlaybackProgress` - video playback progress per user
## More `.env` options
The app also reads these environment variables (all optional):
```bash
PORT=5050 # server port (default: 5050)
JWT_EXPIRES_IN=8h # JWT expiry (default: 8h)
VIDEO_FILES_DIR=/abs/path/to/video # where server video files live (default: ./public/video)
VIDEO_SESSION_MAX_AGE_SECONDS=28800 # video_auth cookie max-age (default: 8h)
MOVIES_CACHE_TTL_MS=30000 # movies route cache TTL (default: 30000ms)
```
Notes:
- The server serves the UI from `public/` (open `http://localhost:5050` by default unless you override `PORT`).
- If you use `video_file` in a movie record, it must be a **relative path** under `VIDEO_FILES_DIR` (the backend blocks traversal/absolute paths).
## Python video server (optional)
There is also a standalone Python server in `python_video_server/` that mirrors `/api/video/:id` (Range streaming + JWT + Mongo). See `python_video_server/README.md` for setup and usage instructions.
run with **node .\index.js**
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+15 -1
View File
@@ -2,12 +2,14 @@ const express = require("express");
const mongoose = require("mongoose"); const mongoose = require("mongoose");
const dotenv = require("dotenv"); const dotenv = require("dotenv");
const cors = require("cors"); const cors = require("cors");
const compression = require("compression");
dotenv.config(); dotenv.config();
const app = express(); const app = express();
// Middleware // Middleware
app.use(cors()); app.use(cors());
app.use(compression());
app.use(express.json()); app.use(express.json());
// Connect to MongoDB // Connect to MongoDB
@@ -20,12 +22,24 @@ mongoose
const userRoutes = require("./routes/users"); const userRoutes = require("./routes/users");
const movieRoutes = require("./routes/movies"); const movieRoutes = require("./routes/movies");
const versionRoutes = require("./routes/version"); const versionRoutes = require("./routes/version");
const settingsRoutes = require("./routes/settings");
const adminDbRoutes = require("./routes/adminDb");
const videoRoutes = require("./routes/video");
const subtitlesRoutes = require("./routes/subtitles");
const picksRoutes = require("./routes/picks");
const categoriesRoutes = require("./routes/categories");
app.use(express.static('public')); app.use(express.static('public'));
app.use("/api/users", userRoutes); app.use("/api/users", userRoutes);
app.use("/api/movies", movieRoutes); app.use("/api/movies", movieRoutes);
app.use("/api/version", versionRoutes); app.use("/api/version", versionRoutes);
app.use("/api/settings", settingsRoutes);
app.use("/api/admin", adminDbRoutes);
app.use("/api/video", videoRoutes);
app.use("/api/subtitles", subtitlesRoutes);
app.use("/api/picks", picksRoutes);
app.use("/api/categories", categoriesRoutes);
// Start Server // Start Server
const PORT = process.env.PORT || 5000; const PORT = process.env.PORT || 5050;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`)); app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
+38 -2
View File
@@ -5,10 +5,46 @@ const movieSchema = new mongoose.Schema({
title: { type: String, required: false }, title: { type: String, required: false },
description: { type: String, required: false }, description: { type: String, required: false },
rating: { type: String, required: false }, rating: { type: String, required: false },
runtime: { type: String, required: false },
poster: { type: String, required: false }, poster: { type: String, required: false },
cam: { type: Boolean, required: false, default: false },
// Oscar pool year / edition (used to segment movies + stats)
year: { type: Number, required: false, index: true },
category: { type: String, required: true }, category: { type: String, required: true },
vod_link: { type: String, required: true }, // Legacy "one field" player source.
// Kept for backwards-compat. New UI prefers video_src / embed_src.
vod_link: { type: String, required: false },
// Player configuration (optional; used by player.html)
player_mode: { type: String, required: false, enum: ["auto", "video", "embed"], default: "auto" },
video_src: { type: String, required: false }, // direct video file URL or HLS (.m3u8)
embed_src: { type: String, required: false }, // URL or iframe code
// Server-hosted file (relative path under VIDEO_FILES_DIR, default public/video)
// If set, the backend can stream it via /api/video/:movieId (Range/seek supported).
video_file: { type: String, required: false },
// Optional low-quality server file (relative path under VIDEO_FILES_DIR, default public/video)
// Served when the player requests ?quality=low on /api/video/:movieId.
video_file_low: { type: String, required: false },
// Optional subtitles (server-hosted .vtt, streamed via /api/subtitles/:movieId/:subtitleId)
subtitles: [{
file: { type: String, required: true },
lang: { type: String, required: false },
label: { type: String, required: false },
default: { type: Boolean, required: false, default: false },
}],
// Legacy single-subtitle fields (kept for backward compatibility)
subtitle_file: { type: String, required: false },
subtitle_lang: { type: String, required: false },
subtitle_label: { type: String, required: false },
subtitle_default: { type: Boolean, required: false, default: false },
watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }] watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
}); }, { timestamps: true });
// Common read patterns:
// - list movies for a year, sorted by title
// - check "last update" per year (sort by updatedAt)
movieSchema.index({ year: 1, title: 1 });
movieSchema.index({ year: 1, updatedAt: -1 });
movieSchema.index({ year: 1, imdb_id: 1 });
module.exports = mongoose.model("Movie", movieSchema); module.exports = mongoose.model("Movie", movieSchema);
+11
View File
@@ -0,0 +1,11 @@
const mongoose = require("mongoose");
const movieRatingSchema = new mongoose.Schema({
movieId: { type: mongoose.Schema.Types.ObjectId, ref: "Movie", required: true, index: true },
userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
rating: { type: Number, required: true, min: 1, max: 5 },
}, { timestamps: true });
movieRatingSchema.index({ movieId: 1, userId: 1 }, { unique: true });
module.exports = mongoose.model("MovieRating", movieRatingSchema);
+19
View File
@@ -0,0 +1,19 @@
const mongoose = require("mongoose");
const oscarCategorySchema = new mongoose.Schema({
year: { type: Number, required: true, index: true },
categoryNumber: { type: Number, required: true }, // 1-23 from the sheet
categoryName: { type: String, required: true }, // e.g., "Best Picture"
nominees: [{
name: { type: String, required: true }, // e.g., "Anora" or "Sean Baker, Anora"
isWinner: { type: Boolean, default: false } // Set when results are known
}],
// Store the raw text from PDF for reference
rawText: { type: String, required: false }
}, { timestamps: true });
// Ensure unique category per year
oscarCategorySchema.index({ year: 1, categoryNumber: 1 }, { unique: true });
module.exports = mongoose.model("OscarCategory", oscarCategorySchema);
+19
View File
@@ -0,0 +1,19 @@
const mongoose = require("mongoose");
const oscarPickSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true },
year: { type: Number, required: true, index: true },
picks: [{
categoryNumber: { type: Number, required: true },
categoryName: { type: String, required: true },
selectedNominee: { type: String, required: true } // The nominee name they selected
}],
submittedAt: { type: Date, default: Date.now },
score: { type: Number, default: null } // Calculated after winners are set
}, { timestamps: true });
// Ensure one pick submission per user per year
oscarPickSchema.index({ userId: 1, year: 1 }, { unique: true });
module.exports = mongoose.model("OscarPick", oscarPickSchema);
+20
View File
@@ -0,0 +1,20 @@
const mongoose = require("mongoose");
const playbackProgressSchema = new mongoose.Schema(
{
userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
movieId: { type: mongoose.Schema.Types.ObjectId, ref: "Movie", required: true, index: true },
// seconds
time: { type: Number, default: 0 },
// seconds (optional, best-effort from the client)
duration: { type: Number, default: null },
updatedAt: { type: Date, default: Date.now },
},
{ versionKey: false }
);
// One progress row per user + movie
playbackProgressSchema.index({ userId: 1, movieId: 1 }, { unique: true });
module.exports = mongoose.model("PlaybackProgress", playbackProgressSchema);
+14
View File
@@ -0,0 +1,14 @@
const mongoose = require("mongoose");
// Simple key/value store for global site settings.
// We currently use it for the "active Oscar year" that drives filtering.
const settingSchema = new mongoose.Schema(
{
key: { type: String, required: true, unique: true, index: true },
value: { type: mongoose.Schema.Types.Mixed, required: true },
},
{ timestamps: true }
);
module.exports = mongoose.model("Setting", settingSchema);
+6
View File
@@ -5,6 +5,12 @@ const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true }, email: { type: String, required: true, unique: true },
password: { type: String, required: true }, password: { type: String, required: true },
role: { type: Number, default: 0 }, role: { type: Number, default: 0 },
// If true, user must change password on next login (e.g. after admin reset)
mustChangePassword: { type: Boolean, default: false },
tempPasswordIssuedAt: { type: Date, default: null },
tempPasswordExpiresAt: { type: Date, default: null },
// Optional. When unset, UI follows system preference.
themePreference: { type: String, enum: ["light", "dark"], default: undefined },
watchedMovies: [{ watchedMovies: [{
imdb_id: { type: String, required: false }, imdb_id: { type: String, required: false },
watchedDate: { type: Date, default: Date.now } // Store the watched date watchedDate: { type: Date, default: Date.now } // Store the watched date
+482 -39
View File
@@ -12,12 +12,15 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"compression": "^1.8.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.22.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment-timezone": "^0.5.47", "moment-timezone": "^0.5.47",
"mongoose": "^8.9.5" "mongoose": "^8.9.5",
"multer": "^2.0.2",
"pdf-parse": "^2.4.5"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.9" "nodemon": "^3.1.9"
@@ -32,6 +35,190 @@
"sparse-bitfield": "^3.0.3" "sparse-bitfield": "^3.0.3"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@types/webidl-conversions": { "node_modules/@types/webidl-conversions": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
@@ -74,6 +261,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -87,13 +280,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.7.9", "version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.4",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },
@@ -148,9 +341,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -186,6 +379,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -261,6 +471,45 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -268,6 +517,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -434,6 +698,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -450,39 +729,39 @@
} }
}, },
"node_modules/express": { "node_modules/express": {
"version": "4.21.2", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
"body-parser": "1.20.3", "body-parser": "~1.20.3",
"content-disposition": "0.5.4", "content-disposition": "~0.5.4",
"content-type": "~1.0.4", "content-type": "~1.0.4",
"cookie": "0.7.1", "cookie": "~0.7.1",
"cookie-signature": "1.0.6", "cookie-signature": "~1.0.6",
"debug": "2.6.9", "debug": "2.6.9",
"depd": "2.0.0", "depd": "2.0.0",
"encodeurl": "~2.0.0", "encodeurl": "~2.0.0",
"escape-html": "~1.0.3", "escape-html": "~1.0.3",
"etag": "~1.8.1", "etag": "~1.8.1",
"finalhandler": "1.3.1", "finalhandler": "~1.3.1",
"fresh": "0.5.2", "fresh": "~0.5.2",
"http-errors": "2.0.0", "http-errors": "~2.0.0",
"merge-descriptors": "1.0.3", "merge-descriptors": "1.0.3",
"methods": "~1.1.2", "methods": "~1.1.2",
"on-finished": "2.4.1", "on-finished": "~2.4.1",
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"path-to-regexp": "0.1.12", "path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7", "proxy-addr": "~2.0.7",
"qs": "6.13.0", "qs": "~6.14.0",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"safe-buffer": "5.2.1", "safe-buffer": "5.2.1",
"send": "0.19.0", "send": "~0.19.0",
"serve-static": "1.16.2", "serve-static": "~1.16.2",
"setprototypeof": "1.2.0", "setprototypeof": "1.2.0",
"statuses": "2.0.1", "statuses": "~2.0.1",
"type-is": "~1.6.18", "type-is": "~1.6.18",
"utils-merge": "1.0.1", "utils-merge": "1.0.1",
"vary": "~1.1.2" "vary": "~1.1.2"
@@ -495,6 +774,21 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express/node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -547,13 +841,15 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.1", "version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12" "mime-types": "^2.1.12"
}, },
"engines": { "engines": {
@@ -686,6 +982,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -823,23 +1134,23 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/jwa": { "node_modules/jwa": {
"version": "1.4.1", "version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"buffer-equal-constant-time": "1.0.1", "buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11", "ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/jws": { "node_modules/jws": {
"version": "3.2.2", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"jwa": "^1.4.1", "jwa": "^1.4.2",
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
@@ -982,6 +1293,27 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/moment": { "node_modules/moment": {
"version": "2.30.1", "version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -1137,6 +1469,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/negotiator": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1243,6 +1593,15 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1258,6 +1617,38 @@
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdf-parse/node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
@@ -1345,6 +1736,20 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -1385,9 +1790,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.6.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -1565,6 +1970,23 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -1635,6 +2057,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undefsafe": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -1651,6 +2079,12 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -1690,6 +2124,15 @@
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
} }
} }
} }
+5 -2
View File
@@ -13,12 +13,15 @@
"axios": "^1.7.9", "axios": "^1.7.9",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"compression": "^1.8.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "express": "^4.22.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"moment-timezone": "^0.5.47", "moment-timezone": "^0.5.47",
"mongoose": "^8.9.5" "mongoose": "^8.9.5",
"multer": "^2.0.2",
"pdf-parse": "^2.4.5"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.1.9" "nodemon": "^3.1.9"
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="changePassword.title">Oscar Pool - Change Password</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
</head>
<body class="bg-light">
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="M4.93 4.93l1.41 1.41"></path>
<path d="M17.66 17.66l1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="M4.93 19.07l1.41-1.41"></path>
<path d="M17.66 6.34l1.41-1.41"></path>
</svg>
</span>
<span class="theme-toggle-thumb" aria-hidden="true"></span>
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
</svg>
</span>
</button>
<div class="container d-flex justify-content-center align-items-center vh-100">
<div class="card p-4" style="max-width: 420px; width: 100%;">
<div class="text-center mb-3">
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Oscar Pool">
</div>
<div class="alert alert-warning" role="alert" data-i18n="changePassword.tempPassword">
Your account uses a <strong>temporary password</strong>. Please set a new password to continue.
</div>
<div id="change-password-error" class="alert alert-danger d-none" role="alert"></div>
<form id="change-password-form">
<div class="mb-3">
<label for="new-password" class="form-label" data-i18n="changePassword.newPassword">New password</label>
<input type="password" class="form-control" id="new-password" minlength="8" required>
<div class="form-text" data-i18n="changePassword.minChars">Minimum 8 characters.</div>
</div>
<div class="mb-3">
<label for="confirm-password" class="form-label" data-i18n="auth.confirmPassword">Confirm password</label>
<input type="password" class="form-control" id="confirm-password" minlength="8" required>
</div>
<button type="submit" class="btn btn-primary w-100" data-i18n="common.save">Save</button>
</form>
<button type="button" id="log-off" class="btn btn-outline-danger w-100 mt-3" data-i18n="nav.logout">Log out</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="./js/app_change-password.js"></script>
</body>
</html>
+126 -31
View File
@@ -3,9 +3,30 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars 2025 - Checklist</title> <title data-i18n="checklist.title">Oscar Pool - Checklist</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet"> <link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style> <style>
.navbar-custom { .navbar-custom {
background-color: black; background-color: black;
@@ -42,86 +63,160 @@
.navbar-toggler-icon { .navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
} }
/* New styles for table */
.table-hover tbody tr:hover {
background-color: #f1f1f1;
cursor: pointer;
}
</style> </style>
</head> </head>
<body class="bg-light"> <body class="bg-light page-loading">
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden" data-i18n="common.loading">Loading…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
</div>
</div>
<!-- Black banner with buttons and user info --> <!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom"> <nav class="navbar navbar-expand-lg navbar-custom">
<div class="container"> <div class="container">
<a class="navbar-brand" style="margin-left: -7px;"> <a class="navbar-brand" href="movies.html">
<img src="img/logo_w.png" style="max-width: 220px; height: 70px; object-fit: contain;"> <img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a> </a>
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav"> <div class="navbar-nav">
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a> <a href="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a> <a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a> <a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none" data-i18n="nav.admin">Admin</a>
</div> </div>
<!-- Move user info inside collapsible navbar and align it properly --> <!-- Move user info inside collapsible navbar and align it properly -->
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center"> <div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text me-3"></span> <span id="user-name" class="navbar-text" data-user-name></span>
<button id="log-off" class="btn btn-danger">Déconnexion</button> <button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="M4.93 4.93l1.41 1.41"></path>
<path d="M17.66 17.66l1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="M4.93 19.07l1.41-1.41"></path>
<path d="M17.66 6.34l1.41-1.41"></path>
</svg>
</span>
<span class="theme-toggle-thumb" aria-hidden="true"></span>
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
<div class="container mt-5"> <div class="container mt-4">
<h2>Checklist</h2> <!-- Summary card (match Stats page spacing/structure) -->
<div id="watched-ratio" class="ratio"></div> <div class="card shadow-sm">
<h3 class="fs-6" style="margin-bottom: -1px;">Temps avant les Oscars (2 mars 2025) - <span id="time-left"></span></h3> <div class="card-body">
<h3 class="fs-6"><span id="movies-per-day"></span></h3> <div class="d-flex flex-column gap-1">
<div class="progress my-3"> <div id="watched-ratio" class="fw-semibold">—</div>
<div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%; background-color: red;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div> <div class="text-muted small" id="time-until-oscars-container">
<span data-i18n="movies.timeUntilOscarsLabel">Time until the Oscars</span>
&nbsp;(<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
</div> </div>
<div class="text-muted small"><span id="movies-per-day"></span></div>
</div>
<div class="progress mt-3" style="height: 10px;">
<div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%; background-color: red;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-busy="true"></div>
</div>
</div>
</div>
<!-- Table for movies --> <!-- Table for movies -->
<table class="table table-hover movie-table"> <div class="table-responsive mt-3">
<table class="table table-striped table-hover align-middle movie-table mb-0">
<thead> <thead>
<tr> <tr>
<th scope="col">Vu</th> <th scope="col" data-i18n="checklist.seen">Seen</th>
<th scope="col">Titre</th> <th scope="col" data-i18n="checklist.filmTitle">Title</th>
<th scope="col">Date</th> <th scope="col" data-i18n="checklist.date">Date</th>
</tr> </tr>
</thead> </thead>
<tbody id="movie-table-body"> <tbody id="movie-table-body">
<!-- Movie rows will be inserted here --> <tr>
<td colspan="3" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true" data-i18n="common.loading">Loading…</td>
</tr>
</tbody> </tbody>
</table> </table>
</div>
<br><br> <br><br>
</div> </div>
<!-- Include confetti-js library --> <!-- Include confetti-js library -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]"></script>
<!-- Uncheck Confirmation Modal -->
<div class="modal fade" id="uncheckConfirmModal" tabindex="-1" aria-labelledby="uncheckConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="uncheckConfirmModalLabel" data-i18n="checklist.confirmRemoval">Confirm removal</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p id="uncheck-confirm-text">Are you sure you want to remove <strong id="uncheckMovieTitle"></strong> from your list?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.cancel">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmUncheckBtn" data-i18n="common.remove">Remove</button>
</div>
</div>
</div>
</div>
<!-- Reward Modal --> <!-- Reward Modal -->
<div class="modal fade" id="videoModal" tabindex="-1" aria-labelledby="videoModalLabel" aria-hidden="true"> <div class="modal fade" id="videoModal" tabindex="-1" aria-labelledby="videoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="videoModalLabel">Félicitations! very nice 🎉🎉🎉</h5> <h5 class="modal-title" id="videoModalLabel" data-i18n="checklist.congratulations">Congratulations! very nice 🎉🎉🎉</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body text-center"> <div class="modal-body text-center">
<video id="rewardVideo" width="75%" controls> <div id="completion-modal-custom" class="text-start d-none"></div>
<source src="video/reward.mp4" type="video/mp4"> <div id="completion-modal-text" class="text-start mb-3" style="white-space: pre-wrap;"></div>
Votre navigateur ne supporte pas la vidéo.
<video id="rewardVideo" width="75%" controls class="d-none">
<source id="rewardVideoSource" src="video/reward.mp4" type="video/mp4">
<span data-i18n="checklist.browserNoVideo">Your browser does not support video.</span>
</video> </video>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Footer Section -->
<footer>
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="./js/app_checklist.js"></script> <script src="./js/app_checklist.js"></script>
<script src="./js/app_version.js"></script>
</body> </body>
</html> </html>
+1452
View File
File diff suppressed because it is too large Load Diff
+1517 -4
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+78 -15
View File
@@ -3,10 +3,26 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars 2025</title> <title data-i18n="common.oscarPool">Oscar Pool</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet"> <link href="./css/app.css" rel="stylesheet">
<style> <style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
/* Ensure the body takes the full height */ /* Ensure the body takes the full height */
html, body { html, body {
height: 100%; height: 100%;
@@ -21,40 +37,87 @@
} }
</style> </style>
<script src="./js/localization.js" defer></script>
</head> </head>
<body class="bg-light"> <body class="bg-light">
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="M4.93 4.93l1.41 1.41"></path>
<path d="M17.66 17.66l1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="M4.93 19.07l1.41-1.41"></path>
<path d="M17.66 6.34l1.41-1.41"></path>
</svg>
</span>
<span class="theme-toggle-thumb" aria-hidden="true"></span>
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
</svg>
</span>
</button>
<div class="container"> <div class="container">
<div class="card p-4" style="max-width: 400px; margin: auto;"> <!-- First-time setup card (hidden by default) -->
<div id="setup-card" class="card p-4 d-none" style="max-width: 450px; margin: auto;">
<div class="text-center mb-3">
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Oscar Pool">
</div>
<div class="alert alert-info mb-3">
<strong data-i18n="auth.firstTimeSetup">First time setup</strong><br>
<span data-i18n="auth.noAdminExists">No administrator exists. Create the first admin account to get started.</span>
</div>
<form id="setup-form">
<div class="mb-3">
<label for="setup-name" class="form-label" data-i18n="auth.name">Name</label>
<input type="text" class="form-control" id="setup-name" data-i18n-placeholder="auth.namePlaceholder" placeholder="Your name" required>
</div>
<div class="mb-3">
<label for="setup-email" class="form-label" data-i18n="auth.email">Email</label>
<input type="email" class="form-control" id="setup-email" data-i18n-placeholder="auth.emailPlaceholder" placeholder="[email protected]" required>
</div>
<div class="mb-3">
<label for="setup-password" class="form-label" data-i18n="auth.password">Password</label>
<input type="password" class="form-control" id="setup-password" minlength="6" required>
<div class="form-text" data-i18n="auth.passwordMin" data-i18n-params='{"min": 6}'>Minimum 6 characters</div>
</div>
<div class="mb-3">
<label for="setup-password-confirm" class="form-label" data-i18n="auth.confirmPassword">Confirm password</label>
<input type="password" class="form-control" id="setup-password-confirm" minlength="6" required>
</div>
<div id="setup-error" class="alert alert-danger d-none" role="alert"></div>
<button type="submit" class="btn btn-warning w-100" data-i18n="auth.createAdminAccount">Create admin account</button>
</form>
</div>
<!-- Normal login card -->
<div id="login-card" class="card p-4" style="max-width: 400px; margin: auto;">
<div class="text-center mb-4"> <div class="text-center mb-4">
<img src="img/logo.png" style="width: 80%;"> <img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Oscar Pool">
</div> </div>
<form id="login-form"> <form id="login-form">
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">Courriel</label> <label for="email" class="form-label" data-i18n="auth.email">Email</label>
<input type="email" class="form-control" id="email" required> <input type="email" class="form-control" id="email" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="password" class="form-label">Mot de passe</label> <label for="password" class="form-label" data-i18n="auth.password">Password</label>
<input type="password" class="form-control" id="password" required> <input type="password" class="form-control" id="password" required>
</div> </div>
<button type="submit" class="btn btn-primary w-100">Connexion</button> <button type="submit" class="btn btn-primary w-100" data-i18n="auth.login">Login</button>
</form> </form>
<p class="mt-3 text-center">Pas de compte? <a href="/register.html">Enregistrez-vous</a></p> <p class="mt-3 text-center"><span data-i18n="auth.noAccount">No account?</span> <a href="/register.html" data-i18n="auth.signUp">Sign up</a></p>
</div> </div>
<!-- Footer Section -->
<footer>
<p id="footer-copy"><a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a> - deux mille vingt-cinq</p>
<p id="footer-text"></p>
</footer>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="./js/app_index.js"></script> <script src="./js/app_index.js"></script>
<script src="./js/app_version.js"></script>
</body> </body>
</html> </html>
+137
View File
@@ -0,0 +1,137 @@
function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function isTokenExpired(decoded) {
const currentTime = Math.floor(Date.now() / 1000);
return !!decoded.exp && decoded.exp < currentTime;
}
async function applyActiveOscarYearToTitle() {
const base = 'Pool Oscars';
const current = String(document.title || base);
const sep = ' - ';
const idx = current.indexOf(sep);
const suffix = idx >= 0 ? current.slice(idx) : '';
try {
const res = await fetch('/api/settings/year', { method: 'GET' });
const data = res.ok ? await res.json().catch(() => null) : null;
const year = Number(data?.year);
document.title = Number.isInteger(year) ? `${base} (${year})${suffix}` : `${base}${suffix}`;
} catch (_) {
document.title = `${base}${suffix}`;
}
}
window.onload = function () {
// Best-effort: update page title with active year.
applyActiveOscarYearToTitle();
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/';
return;
}
let decoded;
try {
decoded = decodeJwt(token);
} catch (e) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
if (isTokenExpired(decoded)) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
// Only show this page if user must change password.
if (!decoded.mustChangePassword) {
window.location.href = '/movies.html';
return;
}
const errorEl = document.getElementById('change-password-error');
const form = document.getElementById('change-password-form');
function showError(message) {
errorEl.classList.remove('d-none');
errorEl.textContent = message;
}
function clearError() {
errorEl.classList.add('d-none');
errorEl.textContent = '';
}
document.getElementById('log-off').addEventListener('click', function () {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
form.addEventListener('submit', async function (e) {
e.preventDefault();
clearError();
const newPassword = document.getElementById('new-password').value;
const confirmPassword = document.getElementById('confirm-password').value;
if (newPassword.length < 8) {
showError(t('auth.passwordMinLength', 'Password must be at least 8 characters.'));
return;
}
if (newPassword !== confirmPassword) {
showError(t('auth.passwordsDoNotMatch', 'Passwords do not match'));
return;
}
try {
const res = await fetch('/api/users/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ newPassword }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.message || data.error || `Erreur (${res.status})`);
return;
}
if (data.token) {
localStorage.setItem('auth_token', data.token);
} else {
// Fallback: force re-login if no token is returned.
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
window.location.href = '/movies.html';
} catch (err) {
showError(err.message || t('auth.networkError', 'Network error'));
}
});
};
+508 -44
View File
@@ -1,17 +1,338 @@
window.onload = async function () { // i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function createPageLoader(options = {}) {
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || t('common.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
let navResizeObserver = null;
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
const preloadedOverlay = document.getElementById('page-loader-overlay');
const overlay = preloadedOverlay || document.createElement('div');
if (!preloadedOverlay) {
overlay.className = 'page-loader-overlay';
overlay.setAttribute('role', 'status');
overlay.setAttribute('aria-live', 'polite');
overlay.setAttribute('aria-busy', 'true');
overlay.innerHTML = `
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">${title}</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar"></div>
</div>
</div>
`;
} else {
// Keep screen reader label up to date.
const label = overlay.querySelector('.visually-hidden');
if (label) label.textContent = title;
}
const barEl = () => overlay.querySelector('#page-loader-bar');
function clampPct(p) {
const n = Number(p);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function setProgress(p) {
progress = clampPct(p);
const bar = barEl();
if (bar) bar.style.width = `${progress}%`;
}
function setSubtitle(_) {
// Intentionally no-op (we only show the bar now).
}
function getNavHeightPx() {
const nav = document.querySelector('nav.navbar');
if (!nav) return 0;
const rect = nav.getBoundingClientRect();
const h = Number(rect?.height) || 0;
return h > 0 ? Math.round(h) : 0;
}
function updateOverlayTopOffset() {
// Scope the offset to this overlay instance (no global CSS var).
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
}
function ensureMounted() {
if (removed) return;
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
// we still need to compute the navbar offset. Otherwise it defaults to 0px
// and the blur covers the header.
updateOverlayTopOffset();
document.body.classList.add('page-loading');
if (!overlay.isConnected) document.body.appendChild(overlay);
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
if (!navResizeObserver) {
const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') {
try {
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
navResizeObserver.observe(nav);
} catch (_) {
navResizeObserver = null;
}
}
}
}
function hideAndRemoveSoon() {
if (removed) return;
overlay.classList.add('page-loader-hide');
overlay.setAttribute('aria-busy', 'false');
document.body.classList.remove('page-loading');
window.setTimeout(() => {
removed = true;
if (navResizeObserver) {
try { navResizeObserver.disconnect(); } catch (_) {}
navResizeObserver = null;
}
try { overlay.remove(); } catch (_) {}
}, 220);
}
function done() {
setProgress(100);
hideAndRemoveSoon();
}
function fail(_) {
// Don't block the UI on errors: let the page render its own error message.
setProgress(Math.max(progress, 95));
window.setTimeout(() => hideAndRemoveSoon(), 200);
}
ensureMounted();
setProgress(8);
return { setProgress, setSubtitle, done, fail };
}
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.loadingChecklist') : 'Loading checklist',
subtitle: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.fetchingData') : 'Fetching data…'
});
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
const movieTableBody = document.getElementById('movie-table-body');
const watchedRatioEl = document.getElementById('watched-ratio');
function showTableError(message) {
if (!movieTableBody) return;
movieTableBody.innerHTML = `
<tr>
<td colspan="3">
<div class="alert alert-danger my-2" role="alert">
${message || t('common.errorLoading', 'Error loading.')}
</div>
</td>
</tr>
`;
}
const DEFAULT_COMPLETION_MODAL = Object.freeze({
// Default title: use the localized 100% modal title if available.
title: t('admin.modal100.defaultTitle', 'Congratulations! very nice 🎉🎉🎉'),
bodyText: '',
videoSrc: 'video/reward.mp4',
bodyHtml: ''
});
function sanitizeHtml(raw) {
const html = String(raw || '');
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
const withoutOnAttrs = withoutScripts
.replace(/\son\w+="[^"]*"/gi, '')
.replace(/\son\w+='[^']*'/gi, '')
.replace(/\son\w+=\S+/gi, '');
return withoutOnAttrs.trim();
}
async function fetchCompletionModalContent() {
try {
const res = await fetch('/api/settings/completion-modal', { method: 'GET', cache: 'no-cache' });
if (!res.ok) throw new Error('Failed to fetch completion modal');
const data = await res.json();
return {
title: typeof data?.title === 'string' ? data.title : DEFAULT_COMPLETION_MODAL.title,
bodyText: typeof data?.bodyText === 'string' ? data.bodyText : DEFAULT_COMPLETION_MODAL.bodyText,
videoSrc: typeof data?.videoSrc === 'string' ? data.videoSrc : DEFAULT_COMPLETION_MODAL.videoSrc,
bodyHtml: typeof data?.bodyHtml === 'string' ? data.bodyHtml : DEFAULT_COMPLETION_MODAL.bodyHtml,
};
} catch (_) {
return DEFAULT_COMPLETION_MODAL;
}
}
function applyCompletionModalContent(content) {
const titleEl = document.getElementById('videoModalLabel');
const bodyTextEl = document.getElementById('completion-modal-text');
const bodyCustomEl = document.getElementById('completion-modal-custom');
const videoEl = document.getElementById('rewardVideo');
const videoSourceEl = document.getElementById('rewardVideoSource');
const rawTitle = String(content?.title || '').trim();
// If the stored value is the i18n key itself, translate it; otherwise
// use the stored title, falling back to the localized default.
const title = (!rawTitle || rawTitle === 'admin.modal100.defaultTitle')
? t('admin.modal100.defaultTitle', 'Congratulations! very nice 🎉🎉🎉')
: rawTitle;
const bodyText = String(content?.bodyText || '');
const bodyHtml = sanitizeHtml(String(content?.bodyHtml || ''));
const videoSrc = String(content?.videoSrc || '').trim();
if (titleEl) titleEl.textContent = title;
if (bodyCustomEl) {
if (bodyHtml) {
bodyCustomEl.innerHTML = bodyHtml;
bodyCustomEl.classList.remove('d-none');
} else {
bodyCustomEl.innerHTML = '';
bodyCustomEl.classList.add('d-none');
}
}
if (bodyTextEl) {
bodyTextEl.textContent = bodyText || '';
bodyTextEl.classList.toggle('d-none', !bodyText);
}
if (videoEl && videoSourceEl) {
if (videoSrc) {
videoSourceEl.setAttribute('src', videoSrc);
// reload source (so updated src is used)
try { videoEl.load(); } catch (_) {}
videoEl.classList.remove('d-none');
} else {
try { videoEl.pause(); } catch (_) {}
videoEl.classList.add('d-none');
}
}
}
async function fetchActiveYear() {
try {
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' });
if (!res.ok) throw new Error('Failed to fetch active year');
const data = await res.json();
const year = Number(data?.year);
return Number.isInteger(year) ? year : new Date().getFullYear();
} catch (_) {
return new Date().getFullYear();
}
}
async function fetchOscarEffectiveDate(year) {
try {
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET', cache: 'no-cache' });
if (!res.ok) throw new Error('Failed to fetch oscar date');
const data = await res.json();
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
return effectiveDate && /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) ? effectiveDate : `${year}-03-15`;
} catch (_) {
return `${year}-03-15`;
}
}
function parseLocalNoonFromIsoDate(dateStr) {
const d = new Date(`${dateStr}T12:00:00`);
return Number.isNaN(d.getTime()) ? null : d;
}
async function fetchMoviesSummary(year) {
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-cache',
});
if (!res.ok) throw new Error('Failed to fetch movies summary');
return await res.json();
}
const activeYear = await fetchActiveYear();
document.title = `Pool Oscars (${activeYear}) - Checklist`;
const oscarYearEl = document.getElementById('oscar-year');
if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
// Render countdown immediately with a deterministic fallback (then refine from settings).
const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
const timeLeftEl = document.getElementById('time-left');
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
if (oscarDayMonthEl) {
try {
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {
oscarDayMonthEl.textContent = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.march15') : 'March 15';
}
}
const targetDate = new Date('March 2, 2025');
const currentDate = new Date(); const currentDate = new Date();
const timeDifference = targetDate - currentDate; let timeDifference = fallbackDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); let daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
document.getElementById('time-left').textContent = `${daysLeft} jours`; if (timeLeftEl) {
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
const targetDate =
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
fallbackDate;
if (oscarDayMonthEl) {
try {
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = targetDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {}
}
const now = new Date();
timeDifference = targetDate - now;
daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
if (timeLeftEl) {
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
}).catch(() => {});
if (token) { if (token) {
try { try {
const decoded = JSON.parse(atob(token.split('.')[1])); const decoded = JSON.parse(atob(token.split('.')[1]));
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
const userName = decoded.name; const userName = decoded.name;
document.getElementById('user-name').textContent = userName; document.getElementById('user-name').textContent = userName;
if (decoded.admin) {
const adminLink = document.getElementById('admin-control-link');
if (adminLink) adminLink.classList.remove('d-none');
}
const currentTime = Math.floor(Date.now() / 1000); const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) { if (decoded.exp && decoded.exp < currentTime) {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
@@ -25,35 +346,82 @@ window.onload = async function () {
window.location.href = '/'; window.location.href = '/';
} }
const res = await fetch('/api/movies', { if (watchedRatioEl) watchedRatioEl.textContent = '—';
method: 'GET', const progressBarEl = document.getElementById('progress-bar');
headers: { 'Authorization': `Bearer ${token}` } pageLoader.setProgress(18);
});
if (!res.ok) { let movies = [];
let watchedMovies = [];
let watchedMoviesInYear = [];
let movieImdbIds = new Set();
try {
// Start summary fetch immediately so the header can update without waiting for the full list.
const summaryPromise = fetchMoviesSummary(activeYear)
.then((summary) => {
const totalCount = Number(summary?.totalMoviesCount) || 0;
const watchedCount = Number(summary?.watchedMoviesCount) || 0;
const ratioPct = totalCount > 0 ? ((watchedCount / totalCount) * 100).toFixed(1) : '0.0';
if (watchedRatioEl) {
const txt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watchedCount, total: totalCount, percent: ratioPct })
: `Watched: ${watchedCount} / ${totalCount} (${ratioPct}%)`;
watchedRatioEl.innerText = txt;
}
updateProgressBar(watchedCount, totalCount, false);
pageLoader.setProgress(32);
return summary;
})
.catch(() => null);
// Fetch remaining data in parallel.
pageLoader.setSubtitle(t('checklist.loadingMovies', 'Loading movies…'));
pageLoader.setProgress(26);
const [completionModalContent, moviesRes] = await Promise.all([
fetchCompletionModalContent(),
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-cache',
})
]);
applyCompletionModalContent(completionModalContent);
pageLoader.setProgress(55);
if (!moviesRes.ok) {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
window.location.href = '/'; window.location.href = '/';
return; return;
} }
const movies = await res.json(); movies = await moviesRes.json();
pageLoader.setProgress(62);
// Sort movies alphabetically by title // Sort movies alphabetically by title
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' })); movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
const watchedRes = await fetch('/api/movies/watchedMovies', { const summary = await summaryPromise;
method: 'GET', watchedMovies = Array.isArray(summary?.watchedMovies) ? summary.watchedMovies : [];
headers: { 'Authorization': `Bearer ${token}` }
});
let watchedMovies = []; if (progressBarEl) progressBarEl.removeAttribute('aria-busy');
if (watchedRes.ok) { } catch (e) {
watchedMovies = await watchedRes.json(); showTableError((window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.unableToLoad') : 'Unable to load checklist. Please try again later.');
pageLoader.fail((window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.unableToLoadNetwork') : 'Unable to load checklist. Check your connection and try again.');
return;
} }
movieImdbIds = new Set(movies.map((m) => m.imdb_id).filter(Boolean));
watchedMoviesInYear = watchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
const totalMoviesCount = movies.length; const totalMoviesCount = movies.length;
const watchedMoviesCount = watchedMovies.length; const watchedMoviesCount = watchedMoviesInYear.length;
document.getElementById('watched-ratio').innerText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1)}%)`; const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
if (document.getElementById('watched-ratio')) {
const txt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watchedMoviesCount, total: totalMoviesCount, percent: ratioPct })
: `Watched: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
document.getElementById('watched-ratio').innerText = txt;
}
function launchConfetti() { function launchConfetti() {
const duration = 3 * 1000; // 3 seconds const duration = 3 * 1000; // 3 seconds
@@ -80,10 +448,12 @@ window.onload = async function () {
frame(); frame();
} }
function updateProgressBar(watchedCount, totalCount) { function updateProgressBar(watchedCount, totalCount, celebrate = true) {
const progressBar = document.getElementById('progress-bar'); const progressBar = document.getElementById('progress-bar');
const percentage = (watchedCount / totalCount) * 100; if (!progressBar) return;
console.log(percentage); const safeTotal = Number(totalCount);
const safeWatched = Number(watchedCount);
const percentage = safeTotal > 0 && Number.isFinite(safeWatched) ? (safeWatched / safeTotal) * 100 : 0;
progressBar.style.width = `${percentage}%`; progressBar.style.width = `${percentage}%`;
if (percentage <= 50) { if (percentage <= 50) {
@@ -94,20 +464,25 @@ window.onload = async function () {
progressBar.setAttribute('aria-valuenow', Math.round(percentage)); progressBar.setAttribute('aria-valuenow', Math.round(percentage));
if (celebrate) {
const videoModal = new bootstrap.Modal(document.getElementById("videoModal")); const videoModal = new bootstrap.Modal(document.getElementById("videoModal"));
if (percentage === 100) { if (percentage === 100) {
videoModal.show(); videoModal.show();
launchConfetti(); launchConfetti();
document.getElementById("rewardVideo").play(); const rewardVideo = document.getElementById("rewardVideo");
// Only attempt autoplay if we actually have a visible <video>.
if (rewardVideo && !rewardVideo.classList.contains('d-none')) {
rewardVideo.play().catch(() => {});
}
}
} }
} }
updateProgressBar(watchedMoviesCount, totalMoviesCount); updateProgressBar(watchedMoviesCount, totalMoviesCount);
const movieTableBody = document.getElementById('movie-table-body'); if (movieTableBody) movieTableBody.innerHTML = '';
movies.forEach(movie => { movies.forEach(movie => {
const watchedMovie = watchedMovies.find(wm => wm.imdb_id === movie.imdb_id); const watchedMovie = watchedMoviesInYear.find(wm => wm.imdb_id === movie.imdb_id);
const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : ''; const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : '';
const movieRow = document.createElement('tr'); const movieRow = document.createElement('tr');
movieRow.innerHTML = ` movieRow.innerHTML = `
@@ -118,12 +493,9 @@ window.onload = async function () {
<td id="watched-date-${movie.imdb_id}">${watchedDate}</td> <td id="watched-date-${movie.imdb_id}">${watchedDate}</td>
`; `;
// Set initial row color based on watched state // Use Bootstrap table state classes (matches Stats page styling better than custom backgrounds)
if (watchedMovie) { movieRow.classList.toggle('table-success', Boolean(watchedMovie));
movieRow.style.backgroundColor = 'rgba(144, 238, 144, 0.6)'; // Lighter light green with transparency movieRow.classList.toggle('table-danger', !watchedMovie);
} else {
movieRow.style.backgroundColor = 'rgba(255, 99, 71, 0.6)'; // Lighter light red with transparency
}
movieTableBody.appendChild(movieRow); movieTableBody.appendChild(movieRow);
@@ -141,12 +513,81 @@ window.onload = async function () {
const watchedDateCell = document.getElementById(`watched-date-${movie.imdb_id}`); const watchedDateCell = document.getElementById(`watched-date-${movie.imdb_id}`);
const row = event.target.closest('tr'); // Get the parent row const row = event.target.closest('tr'); // Get the parent row
if (isChecked) { // If unchecking, show confirmation modal first
watchedDateCell.textContent = new Date().toLocaleString(); if (!isChecked) {
row.style.backgroundColor = 'rgba(144, 238, 144, 0.6)'; // Apply light green with transparency // Revert the checkbox temporarily until confirmed
} else { event.target.checked = true;
// Show confirmation modal
const uncheckModal = new bootstrap.Modal(document.getElementById('uncheckConfirmModal'));
const movieTitleEl = document.getElementById('uncheckMovieTitle');
const confirmBtn = document.getElementById('confirmUncheckBtn');
if (movieTitleEl) movieTitleEl.textContent = movie.title;
// Remove any previous event listener to avoid duplicates
const newConfirmBtn = confirmBtn.cloneNode(true);
confirmBtn.parentNode.replaceChild(newConfirmBtn, confirmBtn);
newConfirmBtn.addEventListener('click', async () => {
uncheckModal.hide();
// Now actually uncheck
event.target.checked = false;
watchedDateCell.textContent = ''; watchedDateCell.textContent = '';
row.style.backgroundColor = 'rgba(255, 99, 71, 0.6)'; // Apply light red with transparency if (row) {
row.classList.add('table-danger');
row.classList.remove('table-success');
}
await fetch('/api/movies/users/updateWatchedMovies', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ imdb_id: movie.imdb_id, isChecked: false })
});
const updatedWatchedMoviesRes = await fetch('/api/movies/watchedMovies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-cache',
});
if (updatedWatchedMoviesRes.ok) {
const updatedWatchedMovies = await updatedWatchedMoviesRes.json();
watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
const updatedWatchedCount = watchedMoviesInYear.length;
const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
if (document.getElementById('watched-ratio')) {
const updatedTxt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: updatedWatchedCount, total: totalMoviesCount, percent: updatedPct })
: `Watched: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
document.getElementById('watched-ratio').innerText = updatedTxt;
}
updateProgressBar(updatedWatchedCount, totalMoviesCount);
const moviesLeft = totalMoviesCount - updatedWatchedCount;
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
if (document.getElementById('movies-per-day')) {
const perDayText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay })
: `Movies to watch per day: ${moviesPerDay}`;
document.getElementById('movies-per-day').textContent = perDayText;
}
}
});
uncheckModal.show();
return;
}
// Checking a movie - no confirmation needed
watchedDateCell.textContent = new Date().toLocaleString();
if (row) {
row.classList.add('table-success');
row.classList.remove('table-danger');
} }
await fetch('/api/movies/users/updateWatchedMovies', { await fetch('/api/movies/users/updateWatchedMovies', {
@@ -160,26 +601,48 @@ window.onload = async function () {
const updatedWatchedMoviesRes = await fetch('/api/movies/watchedMovies', { const updatedWatchedMoviesRes = await fetch('/api/movies/watchedMovies', {
method: 'GET', method: 'GET',
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-cache',
}); });
if (updatedWatchedMoviesRes.ok) { if (updatedWatchedMoviesRes.ok) {
const updatedWatchedMovies = await updatedWatchedMoviesRes.json(); const updatedWatchedMovies = await updatedWatchedMoviesRes.json();
const updatedWatchedCount = updatedWatchedMovies.length; watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1)}%)`; const updatedWatchedCount = watchedMoviesInYear.length;
const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
if (document.getElementById('watched-ratio')) {
const updatedTxt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: updatedWatchedCount, total: totalMoviesCount, percent: updatedPct })
: `Watched: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
document.getElementById('watched-ratio').innerText = updatedTxt;
}
updateProgressBar(updatedWatchedCount, totalMoviesCount); updateProgressBar(updatedWatchedCount, totalMoviesCount);
// Recalculate and update movies per day // Recalculate and update movies per day
const moviesLeft = totalMoviesCount - updatedWatchedCount; const moviesLeft = totalMoviesCount - updatedWatchedCount;
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft; const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`; if (document.getElementById('movies-per-day')) {
const perDayText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay })
: `Movies to watch per day: ${moviesPerDay}`;
document.getElementById('movies-per-day').textContent = perDayText;
}
} }
}); });
}); });
const moviesLeft = totalMoviesCount - watchedMoviesCount; const moviesLeft = totalMoviesCount - watchedMoviesCount;
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft; const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`; if (document.getElementById('movies-per-day')) {
const perDayText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('checklist.moviesToWatchPerDay', { count: moviesPerDay })
: `Movies to watch per day: ${moviesPerDay}`;
document.getElementById('movies-per-day').textContent = perDayText;
}
pageLoader.setSubtitle('Finalisation de l’affichage…');
pageLoader.setProgress(92);
pageLoader.done();
document.getElementById('log-off').addEventListener('click', () => { document.getElementById('log-off').addEventListener('click', () => {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
@@ -190,7 +653,8 @@ window.onload = async function () {
const videoModal = document.getElementById("videoModal"); const videoModal = document.getElementById("videoModal");
const rewardVideo = document.getElementById("rewardVideo"); const rewardVideo = document.getElementById("rewardVideo");
videoModal.addEventListener("hidden.bs.modal", function () { videoModal.addEventListener("hidden.bs.modal", function () {
if (!rewardVideo) return;
rewardVideo.pause(); rewardVideo.pause();
rewardVideo.currentTime = 0; rewardVideo.currentTime = 0;
}); });
}; });
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
// Runs early (defer) to populate the navbar before page-specific JS.
// Goal: avoid "late" header updates that look like flicker.
(function () {
function base64UrlToJson(b64url) {
const s = String(b64url || '')
.replace(/-/g, '+')
.replace(/_/g, '/');
const padded = s + '='.repeat((4 - (s.length % 4)) % 4);
return JSON.parse(atob(padded));
}
function decodeJwtPayload(token) {
const parts = String(token || '').split('.');
if (parts.length < 2) return null;
try { return base64UrlToJson(parts[1]); } catch (_) { return null; }
}
function isExpired(decoded) {
const exp = Number(decoded?.exp);
if (!Number.isFinite(exp) || exp <= 0) return false;
const now = Math.floor(Date.now() / 1000);
return exp < now;
}
const token = localStorage.getItem('auth_token');
if (!token) return;
const decoded = decodeJwtPayload(token);
if (!decoded) return;
// Fast-path redirect checks (match page scripts behavior).
if (decoded.mustChangePassword && !/\/change-password\.html(\?|#|$)/.test(window.location.pathname)) {
window.location.href = '/change-password.html';
return;
}
if (isExpired(decoded)) {
try { localStorage.removeItem('auth_token'); } catch (_) {}
if (!/\/index\.html$/.test(window.location.pathname) && window.location.pathname !== '/') {
window.location.href = '/';
}
return;
}
const name = decoded.name || '';
const displayName = name;
const nameTargets = document.querySelectorAll('[data-user-name]');
if (nameTargets.length) {
nameTargets.forEach((el) => {
if (el && !el.textContent) el.textContent = displayName;
});
} else {
const nameEl = document.getElementById('user-name');
if (nameEl && !nameEl.textContent) {
nameEl.textContent = displayName;
}
}
const adminLink = document.getElementById('admin-control-link');
if (adminLink && decoded.admin) {
adminLink.classList.remove('d-none');
}
// Check visibility config for picks button
const picksButton = document.getElementById('nav-link-picks');
if (picksButton) {
// Check cached value first for immediate display
const cachedConfig = localStorage.getItem('visibility_config_cache');
let cachedValue = null;
let cachedTimestamp = null;
if (cachedConfig) {
try {
const parsed = JSON.parse(cachedConfig);
cachedValue = parsed.value;
cachedTimestamp = parsed.timestamp;
} catch (e) {
// Invalid cache, ignore
}
}
// Use cached value if it's less than 5 minutes old
const cacheAge = cachedTimestamp ? Date.now() - cachedTimestamp : Infinity;
const useCache = cachedValue !== null && cacheAge < 5 * 60 * 1000;
if (useCache) {
// Show button immediately based on cached value
if (cachedValue.showPicksButton !== false) {
picksButton.style.display = '';
} else {
picksButton.style.display = 'none';
}
} else {
// Default to hiding while we fetch (new installs should start disabled)
picksButton.style.display = 'none';
}
// Fetch fresh value from server
fetch('/api/settings/visibility-config', { cache: 'no-store' })
.then(res => res.json())
.then(config => {
// Cache the result
localStorage.setItem('visibility_config_cache', JSON.stringify({
value: config,
timestamp: Date.now()
}));
// Update display based on fresh value
if (config.showPicksButton !== false) {
picksButton.style.display = '';
} else {
picksButton.style.display = 'none';
}
})
.catch(() => {
// On error, keep current state (already set from cache or default)
});
}
// Highlight the active page in the navbar.
(function markActiveNavLink() {
const currentFile = (String(window.location.pathname || '').split('/').pop() || 'index.html').toLowerCase();
const links = document.querySelectorAll('nav.navbar .navbar-nav a.nav-link[href]');
links.forEach((a) => {
const rawHref = String(a.getAttribute('href') || '');
const hrefFile = rawHref.split('#')[0].split('?')[0].split('/').pop().toLowerCase();
const isCurrent = hrefFile && hrefFile === currentFile;
a.classList.toggle('is-current', isCurrent);
if (isCurrent) {
a.setAttribute('aria-current', 'page');
} else if (a.getAttribute('aria-current') === 'page') {
a.removeAttribute('aria-current');
}
});
})();
})();
+168 -10
View File
@@ -1,11 +1,163 @@
// Check if the user is already logged in by looking for auth_token in localStorage function decodeJwt(token) {
if (localStorage.getItem('auth_token')) { return JSON.parse(atob(token.split('.')[1]));
// If the token is found, redirect to /movies.html }
window.location.href = '/movies.html';
// i18n helper function
function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function translateApiMessage(messageKey, message, fallbackKey, fallbackText) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
if (messageKey) return window.i18n.t(messageKey);
const map = {
"L'utilisateur n'a pas été trouvé": 'auth.userNotFound',
'User not found': 'auth.userNotFound',
"Informations d'identification non valides": 'auth.invalidCredentials',
'Invalid credentials': 'auth.invalidCredentials',
"Le mot de passe temporaire a expiré. Demandez à un admin de réinitialiser votre mot de passe.": 'auth.tempPasswordExpired',
'Temporary password expired. Ask an admin to reset your password.': 'auth.tempPasswordExpired',
};
if (message && map[message]) return window.i18n.t(map[message]);
if (fallbackKey) return window.i18n.t(fallbackKey);
}
} catch (_) {}
return message || fallbackText || '';
}
async function applyActiveOscarYearToTitle() {
const base = 'Pool Oscars';
const current = String(document.title || base);
const sep = ' - ';
const idx = current.indexOf(sep);
const suffix = idx >= 0 ? current.slice(idx) : '';
try {
const res = await fetch('/api/settings/year', { method: 'GET' });
const data = res.ok ? await res.json().catch(() => null) : null;
const year = Number(data?.year);
document.title = Number.isInteger(year) ? `${base} (${year})${suffix}` : `${base}${suffix}`;
} catch (_) {
document.title = `${base}${suffix}`;
}
}
// Fire and forget.
applyActiveOscarYearToTitle();
// Check setup status first
async function checkSetupStatus() {
try {
const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' });
if (!res.ok) return { needsSetup: false };
return await res.json();
} catch (_) {
return { needsSetup: false };
}
}
async function initPage() {
const setupCard = document.getElementById('setup-card');
const loginCard = document.getElementById('login-card');
// Check if setup is needed
const status = await checkSetupStatus();
if (status.needsSetup) {
// Show setup card, hide login card
if (setupCard) setupCard.classList.remove('d-none');
if (loginCard) loginCard.classList.add('d-none');
return;
} }
// Handle login form submission // Check if the user is already logged in
document.getElementById('login-form').addEventListener('submit', async function (e) { const existingToken = localStorage.getItem('auth_token');
if (existingToken) {
try {
const decoded = decodeJwt(existingToken);
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) {
localStorage.removeItem('auth_token');
} else if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
} else {
window.location.href = '/movies.html';
return;
}
} catch (_) {
localStorage.removeItem('auth_token');
}
}
}
// Initialize the page
initPage();
// Handle setup form submission
const setupForm = document.getElementById('setup-form');
if (setupForm) {
setupForm.addEventListener('submit', async function (e) {
e.preventDefault();
const setupError = document.getElementById('setup-error');
const name = document.getElementById('setup-name').value.trim();
const email = document.getElementById('setup-email').value.trim();
const password = document.getElementById('setup-password').value;
const passwordConfirm = document.getElementById('setup-password-confirm').value;
// Hide previous error
if (setupError) setupError.classList.add('d-none');
// Validate passwords match
if (password !== passwordConfirm) {
if (setupError) {
setupError.textContent = 'Les mots de passe ne correspondent pas';
setupError.classList.remove('d-none');
}
return;
}
try {
const res = await fetch('/api/users/setup/admin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password })
});
const data = await res.json();
if (res.ok) {
// Save the token and redirect
localStorage.setItem('auth_token', data.token);
window.location.href = '/movies.html';
} else {
if (setupError) {
setupError.textContent = data.message || t('auth.errorCreatingAccount', 'Error creating account');
setupError.classList.remove('d-none');
}
}
} catch (err) {
if (setupError) {
setupError.textContent = t('auth.networkError', 'Network error. Please try again.');
setupError.classList.remove('d-none');
}
}
});
}
// Handle login form submission
document.getElementById('login-form').addEventListener('submit', async function (e) {
e.preventDefault(); e.preventDefault();
const email = document.getElementById('email').value; const email = document.getElementById('email').value;
@@ -21,9 +173,15 @@ if (localStorage.getItem('auth_token')) {
const data = await res.json(); const data = await res.json();
// Save the token to localStorage // Save the token to localStorage
localStorage.setItem('auth_token', data.token); localStorage.setItem('auth_token', data.token);
window.location.href = '/movies.html'; // Redirect to movies page on success
if (data.mustChangePassword) {
window.location.href = '/change-password.html';
} else { } else {
const error = await res.json(); window.location.href = '/movies.html'; // Redirect to movies page on success
alert(`${error.message}`);
} }
}); } else {
const error = await res.json().catch(() => ({}));
const msg = translateApiMessage(error.messageKey, error.message, 'auth.loginFailed', 'Login failed');
alert(msg);
}
});
+666 -34
View File
@@ -1,16 +1,442 @@
window.onload = async function () { // i18n helper function
const targetDate = new Date('March 2, 2025'); function t(key, fallback = '', params = {}) {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params);
}
let result = fallback;
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
result = result.replace(`{${k}}`, String(params[k]));
});
}
return result;
}
function createPageLoader(options = {}) {
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || t('common.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
let navResizeObserver = null;
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
const preloadedOverlay = document.getElementById('page-loader-overlay');
const overlay = preloadedOverlay || document.createElement('div');
if (!preloadedOverlay) {
overlay.className = 'page-loader-overlay';
overlay.setAttribute('role', 'status');
overlay.setAttribute('aria-live', 'polite');
overlay.setAttribute('aria-busy', 'true');
overlay.innerHTML = `
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">${title}</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar"></div>
</div>
</div>
`;
} else {
// Keep screen reader label up to date.
const label = overlay.querySelector('.visually-hidden');
if (label) label.textContent = title;
}
const barEl = () => overlay.querySelector('#page-loader-bar');
function clampPct(p) {
const n = Number(p);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function setProgress(p) {
progress = clampPct(p);
const bar = barEl();
if (bar) bar.style.width = `${progress}%`;
}
function setSubtitle(_) {
// Intentionally no-op (we only show the bar now).
}
function getNavHeightPx() {
const nav = document.querySelector('nav.navbar');
if (!nav) return 0;
const rect = nav.getBoundingClientRect();
const h = Number(rect?.height) || 0;
return h > 0 ? Math.round(h) : 0;
}
function updateOverlayTopOffset() {
// Scope the offset to this overlay instance (no global CSS var).
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
}
function ensureMounted() {
if (removed) return;
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
// we still need to compute the navbar offset. Otherwise it defaults to 0px
// and the blur covers the header.
updateOverlayTopOffset();
document.body.classList.add('page-loading');
if (!overlay.isConnected) document.body.appendChild(overlay);
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
if (!navResizeObserver) {
const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') {
try {
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
navResizeObserver.observe(nav);
} catch (_) {
navResizeObserver = null;
}
}
}
}
function hideAndRemoveSoon() {
if (removed) return;
overlay.classList.add('page-loader-hide');
overlay.setAttribute('aria-busy', 'false');
document.body.classList.remove('page-loading');
window.setTimeout(() => {
removed = true;
if (navResizeObserver) {
try { navResizeObserver.disconnect(); } catch (_) {}
navResizeObserver = null;
}
try { overlay.remove(); } catch (_) {}
}, 220);
}
function done() {
setProgress(100);
hideAndRemoveSoon();
}
function fail(_) {
// Don't block the UI on errors: let the page render its own error message.
setProgress(Math.max(progress, 95));
window.setTimeout(() => hideAndRemoveSoon(), 200);
}
ensureMounted();
setProgress(8);
return { setProgress, setSubtitle, done, fail };
}
async function waitForImagesIn(containerEl, onProgress) {
if (!containerEl) return;
const imgs = Array.from(containerEl.querySelectorAll('img'));
if (imgs.length === 0) return;
let resolved = 0;
const total = imgs.length;
const notify = () => {
if (typeof onProgress === 'function') onProgress(resolved, total);
};
await Promise.race([
Promise.all(
imgs.map((img) => new Promise((resolve) => {
const finish = () => {
resolved += 1;
notify();
resolve();
};
if (img.complete) return finish();
img.addEventListener('load', finish, { once: true });
img.addEventListener('error', finish, { once: true });
}))
),
new Promise((resolve) => window.setTimeout(resolve, 7000)), // safety timeout
]);
}
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.loadingMovies') : 'Loading movies',
subtitle: (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.fetchingData') : 'Fetching data…'
});
function formatClock(seconds) {
const s = Math.max(0, Math.floor(Number(seconds) || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
return `${m}:${String(sec).padStart(2, '0')}`;
}
function formatRuntime(runtime) {
// Convert "142 min" or "142" to "2h 22min" format
if (!runtime || runtime === 'N/A') return '';
const raw = String(runtime).trim();
// Extract minutes from strings like "142 min", "142min", or just "142"
const match = raw.match(/^(\d+)\s*min/i);
const minutes = match ? parseInt(match[1], 10) : parseInt(raw, 10);
if (!Number.isFinite(minutes) || minutes <= 0) return raw;
if (minutes < 60) return `${minutes}min`;
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return m > 0 ? `${h}h ${m}min` : `${h}h`;
}
async function fetchPlaybackProgressByYear(year, token) {
try {
const res = await fetch(`/api/movies/progress?year=${encodeURIComponent(String(year))}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-store',
});
if (!res.ok) return new Map();
const rows = await res.json().catch(() => []);
const map = new Map();
(Array.isArray(rows) ? rows : []).forEach((r) => {
const movieId = typeof r?.movieId === 'string' ? r.movieId : '';
if (!movieId) return;
const time = Number(r?.time);
const duration = r?.duration === null || r?.duration === undefined ? null : Number(r.duration);
map.set(movieId, {
time: Number.isFinite(time) && time >= 0 ? time : 0,
duration: duration !== null && Number.isFinite(duration) && duration > 0 ? duration : null,
});
});
return map;
} catch (_) {
return new Map();
}
}
function setLoadError(el, message) {
if (!el) return;
el.innerHTML = `
<div class="alert alert-danger my-3" role="alert">
${message || 'Erreur lors du chargement.'}
</div>
`;
}
function normalizeRatingValue(v) {
const n = Number(v);
if (!Number.isFinite(n)) return null;
const rounded = Math.round(n);
if (rounded < 1 || rounded > 5) return null;
return rounded;
}
async function saveUserRating(movieId, rating, token) {
try {
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
},
body: JSON.stringify({ rating }),
});
if (!res.ok) return null;
return await res.json().catch(() => null);
} catch (_) {
return null;
}
}
async function deleteUserRating(movieId, token) {
try {
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, {
method: 'DELETE',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
});
if (!res.ok) return null;
return await res.json().catch(() => null);
} catch (_) {
return null;
}
}
function updateStarUi(wrap, rating) {
if (!wrap) return;
const value = Number(rating);
const user = Number.isFinite(value) ? value : null;
if (wrap?.dataset) {
wrap.dataset.currentRating = user ? String(user) : '';
}
wrap.querySelectorAll('.movie-star').forEach((btn) => {
const starValue = Number(btn.getAttribute('data-value'));
const isActive = user && starValue <= user;
btn.classList.toggle('is-active', !!isActive);
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
});
}
function updateRatingTexts(cardEl, avgRating, ratingsCount, userRating) {
if (!cardEl) return;
const avgEl = cardEl.querySelector('[data-role="avg-rating"]');
const userEl = cardEl.querySelector('[data-role="user-rating"]');
const avg = Number.isFinite(Number(avgRating)) ? Number(avgRating) : null;
const count = Number.isFinite(Number(ratingsCount)) ? Number(ratingsCount) : 0;
const user = Number.isFinite(Number(userRating)) ? Number(userRating) : null;
if (avgEl) {
if (avg && count > 0) {
const avgLabel = `${avg.toFixed(1)}${count ? ` (${count})` : ''}`;
const translated = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.userRating', { rating: avgLabel })
: `User rating ${avgLabel}`;
avgEl.textContent = translated;
avgEl.classList.remove('movie-rating-sub--empty');
} else {
avgEl.textContent = '';
avgEl.classList.add('movie-rating-sub--empty');
}
}
if (userEl) {
userEl.textContent = '';
userEl.classList.add('movie-rating-sub--empty');
}
}
async function fetchActiveYear() {
try {
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' });
if (!res.ok) throw new Error('Failed to fetch active year');
const data = await res.json();
const year = Number(data?.year);
return Number.isInteger(year) ? year : new Date().getFullYear();
} catch (_) {
return new Date().getFullYear();
}
}
async function fetchOscarEffectiveDate(year) {
try {
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET', cache: 'no-cache' });
if (!res.ok) throw new Error('Failed to fetch oscar date');
const data = await res.json();
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
return effectiveDate && /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) ? effectiveDate : `${year}-03-15`;
} catch (_) {
return `${year}-03-15`;
}
}
function parseLocalNoonFromIsoDate(dateStr) {
// Use local noon to avoid DST/midnight edge cases for "days left" math.
const d = new Date(`${dateStr}T12:00:00`);
return Number.isNaN(d.getTime()) ? null : d;
}
async function fetchMoviesSummary(year, token) {
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-cache',
});
if (!res.ok) throw new Error('Failed to fetch movies summary');
return await res.json();
}
function applySummaryToHeader(summary) {
const lastUpdatedEl = document.getElementById('movies-last-updated');
const ratioEl = document.getElementById('watched-ratio');
const lastUpdatedIso = summary?.lastUpdated;
if (lastUpdatedEl) {
if (lastUpdatedIso) {
const d = new Date(lastUpdatedIso);
if (!Number.isNaN(d.getTime())) {
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
try {
lastUpdatedEl.textContent = d.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
} catch (_) {
lastUpdatedEl.textContent = d.toLocaleString();
}
} else {
lastUpdatedEl.textContent = '—';
}
} else {
lastUpdatedEl.textContent = '—';
}
}
const total = Number(summary?.totalMoviesCount) || 0;
const watched = Number(summary?.watchedMoviesCount) || 0;
const ratioPct = total > 0 ? ((watched / total) * 100).toFixed(1) : '0.0';
if (ratioEl) {
const txt = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watched, total: total, percent: ratioPct })
: `Watched: ${watched} / ${total} (${ratioPct}%)`;
ratioEl.innerText = txt;
}
}
const activeYear = await fetchActiveYear();
document.title = `Pool Oscars (${activeYear}) - Movies`;
const oscarYearEl = document.getElementById('oscar-year');
if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
// Render countdown immediately with a deterministic fallback (then refine from settings).
const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
const timeLeftEl = document.getElementById('time-left');
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
if (oscarDayMonthEl) {
try {
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {
oscarDayMonthEl.textContent = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.march15') : 'March 15';
}
}
if (timeLeftEl) {
const currentDate = new Date();
const timeDifference = fallbackDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
const targetDate =
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
fallbackDate;
if (oscarDayMonthEl) {
try {
const locale = (window.i18n && typeof window.i18n.getLanguage === 'function') ? window.i18n.getLanguage() : (document.documentElement.lang || 'en');
oscarDayMonthEl.textContent = targetDate.toLocaleDateString(locale, { day: 'numeric', month: 'long' });
} catch (_) {}
}
if (timeLeftEl) {
const currentDate = new Date(); const currentDate = new Date();
const timeDifference = targetDate - currentDate; const timeDifference = targetDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24)); const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
document.getElementById('time-left').textContent = `${daysLeft} jours`; const daysLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.daysLeft', { days: daysLeft }) : `${daysLeft} days`;
timeLeftEl.textContent = daysLabel;
}
}).catch(() => {});
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
if (token) { if (token) {
try { try {
const decoded = JSON.parse(atob(token.split('.')[1])); const decoded = JSON.parse(atob(token.split('.')[1]));
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
const userName = decoded.name; const userName = decoded.name;
document.getElementById('user-name').textContent = userName; document.getElementById('user-name').textContent = userName;
if (decoded.admin) {
const adminLink = document.getElementById('admin-control-link');
if (adminLink) adminLink.classList.remove('d-none');
}
const currentTime = Math.floor(Date.now() / 1000); const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) { if (decoded.exp && decoded.exp < currentTime) {
@@ -25,9 +451,40 @@ window.onload = async function () {
window.location.href = '/'; window.location.href = '/';
} }
const res = await fetch('/api/movies', { const moviesList = document.getElementById('movies-list');
pageLoader.setProgress(18);
let watchedMoviesInYear = [];
let progressByMovieId = new Map();
try {
// Start summary fetch immediately so header can update ASAP.
const summaryPromise = fetchMoviesSummary(activeYear, token)
.then((data) => {
watchedMoviesInYear = Array.isArray(data?.watchedMovies) ? data.watchedMovies : [];
applySummaryToHeader(data);
pageLoader.setProgress(32);
return data;
})
.catch(() => {
// Leave the placeholders (—) if summary fails; movies list can still load.
const lastUpdatedEl = document.getElementById('movies-last-updated');
if (lastUpdatedEl) lastUpdatedEl.textContent = '—';
const ratioEl = document.getElementById('watched-ratio');
if (ratioEl) ratioEl.textContent = '—';
return null;
});
// Fetch progress in parallel (movies page uses it to show resume/progression).
const progressPromise = fetchPlaybackProgressByYear(activeYear, token)
.then((m) => { progressByMovieId = m instanceof Map ? m : new Map(); })
.catch(() => { progressByMovieId = new Map(); });
pageLoader.setProgress(28);
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
method: 'GET', method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }, headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-cache',
}); });
if (!res.ok) { if (!res.ok) {
@@ -37,50 +494,225 @@ window.onload = async function () {
} }
const movies = await res.json(); const movies = await res.json();
pageLoader.setProgress(55);
// If summary is still in flight, wait for it before rendering watched banners.
await Promise.all([summaryPromise, progressPromise]);
pageLoader.setProgress(62);
// Sort movies alphabetically by title // Sort movies alphabetically by title (server also sorts, but keep client as a fallback)
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' })); movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
const watchedRes = await fetch('/api/movies/watchedMovies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
});
let watchedMovies = [];
if (watchedRes.ok) {
watchedMovies = await watchedRes.json();
}
const moviesList = document.getElementById('movies-list');
const totalMoviesCount = movies.length; const totalMoviesCount = movies.length;
const watchedMoviesCount = watchedMovies.length; const watchedMoviesCount = watchedMoviesInYear.length;
const ratioText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1)}%)`; const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
document.getElementById('watched-ratio').innerText = ratioText; const ratioText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.watchedRatio', { watched: watchedMoviesCount, total: totalMoviesCount, percent: ratioPct })
: `Watched: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
const ratioEl = document.getElementById('watched-ratio');
if (ratioEl) ratioEl.innerText = ratioText;
if (moviesList) moviesList.innerHTML = '';
movies.forEach(movie => { movies.forEach(movie => {
const isChecked = watchedMovies.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id); const isChecked = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id);
const watchedMovie = watchedMovies.find(wm => wm.imdb_id === movie.imdb_id); const watchedMovie = watchedMoviesInYear.find(wm => wm.imdb_id === movie.imdb_id);
const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : ''; const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : '';
const cleanUrl = (v) => {
const s = (typeof v === 'string' ? v.trim() : '');
// Treat placeholders as "no link" so you never need to store '#'
if (!s || s === '#' || s.toLowerCase() === 'about:blank') return '';
return s;
};
const hasVideoSrc = !!cleanUrl(movie?.video_src);
const hasEmbedSrc = !!cleanUrl(movie?.embed_src);
const hasNewPlayerSource = hasVideoSrc || hasEmbedSrc;
const legacyUrl = cleanUrl(movie?.vod_link);
const hasLegacy = !!legacyUrl;
const movieId = movie && movie._id ? String(movie._id) : '';
const userRatingAvg = Number(movie?.user_rating_avg);
const userRatingCount = Number(movie?.user_rating_count) || 0;
const userRating = Number(movie?.user_rating);
const userRatingText =
Number.isFinite(userRatingAvg) && userRatingAvg > 0
? `${userRatingAvg.toFixed(1)}${userRatingCount ? ` (${userRatingCount})` : ''}`
: '';
const avgHtml = userRatingText
? ((window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.userRating', { rating: userRatingText })
: `User rating ${userRatingText}`)
: '';
const userRatingOwn =
Number.isFinite(userRating) && userRating > 0 ? `${userRating}/5` : '';
const starsHtml = Array.from({ length: 5 }, (_, idx) => {
const value = idx + 1;
const isActive = Number.isFinite(userRating) && userRating >= value;
return `<button type="button" class="movie-star${isActive ? ' is-active' : ''}" data-value="${value}" aria-label="${value} étoile${value > 1 ? 's' : ''}" aria-pressed="${isActive ? 'true' : 'false'}">★</button>`;
}).join('');
// Navigation:
// - Prefer in-app player when we have a non-legacy source
// - If only legacy exists, open the legacy URL
// - If there is no source at all, render as non-clickable (no "#")
const playerUrl = (movie && movie._id && hasNewPlayerSource)
? `/player.html?id=${encodeURIComponent(movie._id)}`
: (hasLegacy ? legacyUrl : '');
const isClickable = !!playerUrl;
// Progress: only show for in-app player sources (not legacy vod_link, not "no source").
// Also avoid showing a meaningless "0:00" if they never really started.
let progressHtml = '';
if (hasNewPlayerSource && movieId) {
const p = progressByMovieId.get(movieId);
const time = Number(p?.time) || 0;
const duration = p?.duration === null || p?.duration === undefined ? null : Number(p.duration);
if (time > 1) {
if (duration && Number.isFinite(duration) && duration > 0) {
const pct = Math.max(0, Math.min(100, (time / duration) * 100));
// Hide "resume/progress" once we're basically at the credits.
// Backend uses 95% as the "credit roll" threshold too.
if (pct < 95) {
const resumeText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.resumeAt', { time: `<span class="fw-semibold text-dark">${formatClock(time)}</span>` })
: `Resume at <span class="fw-semibold text-dark">${formatClock(time)}</span>`;
progressHtml = `
<div class="mt-2">
<div class="d-flex justify-content-between align-items-center text-muted small">
<span>${resumeText}</span>
<span>${pct.toFixed(0)}%</span>
</div>
<div class="progress" style="height: 6px;">
<div class="progress-bar" role="progressbar" style="width: ${pct.toFixed(2)}%;" aria-valuenow="${pct.toFixed(0)}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
`;
}
} else {
const resumeText = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('movies.resumeAt', { time: `<span class="fw-semibold text-dark">${formatClock(time)}</span>` })
: `Resume at <span class="fw-semibold text-dark">${formatClock(time)}</span>`;
progressHtml = `
<div class="mt-2 text-muted small">
${resumeText}
</div>
`;
}
}
}
const camBadge = movie?.cam ? '<span class="badge bg-warning text-dark position-absolute top-0 end-0 m-2 cam-badge">CAM</span>' : '';
const watchedBadge = isChecked
? `<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.watched') : 'WATCHED'}</span>`
: '';
const noSourceHtml = !isClickable
? `<div class="text-muted small mb-1">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.noSource') : 'No source'}</div>`
: '';
const posterBlock = `
<div class="text-center pt-3 px-3 position-relative movie-poster-wrap">
${camBadge}
<img src="${movie.poster}" class="img-fluid rounded movie-poster" alt="${movie.title}">
</div>
`;
const movieDiv = document.createElement('div'); const movieDiv = document.createElement('div');
movieDiv.classList.add('col-md-4', 'mb-4', 'movie-card'); movieDiv.classList.add('col-12', 'col-sm-6', 'col-lg-4', 'movie-card');
movieDiv.setAttribute('data-imdb-id', movie.imdb_id); movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
movieDiv.innerHTML = ` movieDiv.innerHTML = `
<div class="card"> <div class="card h-100 shadow-sm">
${isChecked ? '<div class="watched-banner">VISIONNÉ</div>' : ''} ${watchedBadge}
<a href="${movie.vod_link}"><img src="${movie.poster}" class="card-img-top" alt="${movie.title}" style="width: 75%; display: block; margin: 0 auto; padding: 10px;"></a> ${
<div class="card-body"> isClickable
<h5 class="card-title d-flex justify-content-between align-items-center"> ? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
${movie.title} ${posterBlock}
<span>⭐ ${movie.rating} <a href="https://www.imdb.com/title/${movie.imdb_id}/"><img src="/img/imdb.png"></a></span> </a>`
: `${posterBlock}`
}
<div class="card-body d-flex flex-column">
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="flex-grow-1">
<h5 class="card-title mb-1">
${
isClickable
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none text-dark">${movie.title}</a>`
: `<span class="text-dark">${movie.title}</span>`
}
</h5> </h5>
<p class="fw-bold fst-italic card-text" style="font-size: 0.75rem;">${movie.category}</p> ${movie.runtime && movie.runtime !== 'N/A' ? `<div class="text-muted small mb-1">${formatRuntime(movie.runtime)}</div>` : ''}
<p class="card-text">${movie.description}</p> <div class="movie-rating-block mt-1">
${isChecked ? `<p class="card-text"><strong>Regardé le:</strong> ${watchedDate}</p>` : ''} <div class="movie-rating-sub movie-rating-avg${avgHtml ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${avgHtml ? avgHtml : ''}</div>
</div>
</div>
<div class="movie-imdb-wrap">
<div class="movie-imdb-block">
${movie.rating ? `<span>⭐ ${movie.rating}</span>` : ''}
<a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.viewOnImdb') : 'View on IMDb'}">
<span class="imdb-icon" aria-hidden="true">IMDb</span>
</a>
</div>
<div class="movie-rating-stars" role="group" aria-label="${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('player.rateThisFilm') : 'Rate this film'}" data-movie-id="${movieId}" data-current-rating="${Number.isFinite(userRating) ? userRating : ''}">
${starsHtml}
</div>
</div>
</div>
${noSourceHtml}
<div class="movie-info-box">
<details class="movie-info-categories">
<summary class="movie-info-summary">
<span class="movie-info-summary-title">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.nominatedCategories') : 'Nominated categories'}</span>
<span class="movie-info-summary-arrow" aria-hidden="true">▾</span>
</summary>
<div class="movie-info-category">${movie.category}</div>
</details>
<p class="movie-info-desc">${movie.description}</p>
</div>
</div>
<div class="mt-auto">
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">${(window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('checklist.watchedOn') : 'Watched on:'}</span> ${watchedDate}</div>` : ''}
${progressHtml}
</div>
</div> </div>
</div>`; </div>`;
moviesList.appendChild(movieDiv); moviesList.appendChild(movieDiv);
const starsWrap = movieDiv.querySelector('.movie-rating-stars');
if (starsWrap) {
if (!token || !movieId) {
starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.setAttribute('disabled', 'disabled'));
} else {
starsWrap.addEventListener('click', async (event) => {
const target = event.target;
if (!target || !target.classList.contains('movie-star')) return;
const value = normalizeRatingValue(target.getAttribute('data-value'));
if (!value) return;
starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.setAttribute('disabled', 'disabled'));
const current = Number(starsWrap.dataset.currentRating || '');
const shouldClear = current && current === value;
const result = shouldClear
? await deleteUserRating(movieId, token)
: await saveUserRating(movieId, value, token);
if (result) {
updateStarUi(starsWrap, result?.userRating);
updateRatingTexts(movieDiv, result?.averageRating, result?.ratingsCount, result?.userRating);
}
starsWrap.querySelectorAll('.movie-star').forEach((btn) => btn.removeAttribute('disabled'));
}); });
}
}
});
pageLoader.setSubtitle('Finalisation de l’affichage…');
pageLoader.setProgress(80);
await waitForImagesIn(moviesList, (loaded, total) => {
// Move 80% -> 98% as posters resolve
const pct = 80 + (loaded / Math.max(1, total)) * 18;
pageLoader.setProgress(pct);
});
pageLoader.done();
} catch (e) {
setLoadError(moviesList, t('movies.errorLoadingList', 'Unable to load movie list. Try again in a moment.'));
pageLoader.fail(t('movies.errorLoading', 'Unable to load movies. Check your connection and try again.'));
}
document.getElementById('log-off').addEventListener('click', function () { document.getElementById('log-off').addEventListener('click', function () {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
@@ -104,7 +736,7 @@ window.onload = async function () {
movieCards.forEach(card => { movieCards.forEach(card => {
const imdbId = card.getAttribute('data-imdb-id'); const imdbId = card.getAttribute('data-imdb-id');
const isWatched = watchedMovies.some(watchedMovie => watchedMovie.imdb_id === imdbId); const isWatched = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === imdbId);
if (showUnwatchedOnly && isWatched) { if (showUnwatchedOnly && isWatched) {
card.style.display = 'none'; card.style.display = 'none';
@@ -115,4 +747,4 @@ window.onload = async function () {
} }
filterMovies(); filterMovies();
}; });
+937
View File
@@ -0,0 +1,937 @@
(function() {
'use strict';
function createPageLoader(options = {}) {
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || t('picks.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
let navResizeObserver = null;
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
const preloadedOverlay = document.getElementById('page-loader-overlay');
const overlay = preloadedOverlay || document.createElement('div');
if (!preloadedOverlay) {
overlay.className = 'page-loader-overlay';
overlay.setAttribute('role', 'status');
overlay.setAttribute('aria-live', 'polite');
overlay.setAttribute('aria-busy', 'true');
overlay.innerHTML = `
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">${title}</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar"></div>
</div>
</div>
`;
} else {
// Keep screen reader label up to date.
const label = overlay.querySelector('.visually-hidden');
if (label) label.textContent = title;
}
const barEl = () => overlay.querySelector('#page-loader-bar');
function clampPct(p) {
const n = Number(p);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function setProgress(p) {
progress = clampPct(p);
const bar = barEl();
if (bar) bar.style.width = `${progress}%`;
}
function setSubtitle(_) {
// Intentionally no-op (we only show the bar now).
}
function getNavHeightPx() {
const nav = document.querySelector('nav.navbar');
if (!nav) return 0;
const rect = nav.getBoundingClientRect();
const h = Number(rect?.height) || 0;
return h > 0 ? Math.round(h) : 0;
}
function updateOverlayTopOffset() {
// Scope the offset to this overlay instance (no global CSS var).
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
}
function ensureMounted() {
if (removed) return;
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
// we still need to compute the navbar offset. Otherwise it defaults to 0px
// and the blur covers the header.
updateOverlayTopOffset();
document.body.classList.add('page-loading');
if (!overlay.isConnected) document.body.appendChild(overlay);
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
if (!navResizeObserver) {
const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') {
try {
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
navResizeObserver.observe(nav);
} catch (_) {
navResizeObserver = null;
}
}
}
}
function hideAndRemoveSoon() {
if (removed) return;
overlay.classList.add('page-loader-hide');
overlay.setAttribute('aria-busy', 'false');
document.body.classList.remove('page-loading');
window.setTimeout(() => {
removed = true;
if (navResizeObserver) {
try { navResizeObserver.disconnect(); } catch (_) {}
navResizeObserver = null;
}
try { overlay.remove(); } catch (_) {}
}, 220);
}
function done() {
setProgress(100);
hideAndRemoveSoon();
}
function fail(_) {
// Don't block the UI on errors: let the page render its own error message.
setProgress(Math.max(progress, 95));
window.setTimeout(() => hideAndRemoveSoon(), 200);
}
ensureMounted();
setProgress(8);
return { setProgress, setSubtitle, done, fail };
}
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/';
return;
}
function decodeJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch {
return null;
}
}
const decoded = decodeJwt(token);
if (!decoded) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
// Check if admin and show admin tabs
if (decoded.admin) {
const adminTabWinners = document.getElementById('tab-winners');
if (adminTabWinners) adminTabWinners.classList.remove('d-none');
}
// Logout
const logOffBtn = document.getElementById('log-off');
if (logOffBtn) {
logOffBtn.addEventListener('click', () => {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
}
let categories = [];
let myPicks = {};
let currentYear = null;
let isSaving = false; // Prevent multiple simultaneous saves
async function fetchActiveYear() {
try {
const res = await fetch('/api/settings/year');
if (!res.ok) throw new Error('Failed to fetch active year');
const data = await res.json();
return Number(data.year) || new Date().getFullYear();
} catch (err) {
console.error('Error fetching active year:', err);
return new Date().getFullYear();
}
}
function showAlert(message, type = 'info') {
const container = document.getElementById('alert-container');
if (!container) return;
container.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 5000);
}
async function fetchCategories(pageLoader) {
try {
// Use active year if not set
if (!currentYear) {
currentYear = await fetchActiveYear();
document.title = t('picks.pageTitleWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear });
}
if (pageLoader) pageLoader.setProgress(18);
const res = await fetch(`/api/picks/categories?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
throw new Error('Failed to fetch categories');
}
if (pageLoader) pageLoader.setProgress(40);
const data = await res.json();
categories = data.categories || [];
currentYear = data.year || currentYear;
document.getElementById('current-year').textContent = currentYear;
if (categories.length === 0) {
const noCats = t('picks.noCategoriesFound', 'No categories found for year {year}', { year: currentYear });
const adminHint = t('picks.adminMustImportCategories', 'An administrator must first import the categories.');
document.getElementById('categories-container').innerHTML = `
<div class="alert alert-warning">
${noCats}
${adminHint}
</div>
`;
if (pageLoader) pageLoader.done();
return;
}
if (pageLoader) pageLoader.setProgress(60);
renderCategories();
await loadMyPicks(pageLoader);
if (pageLoader) pageLoader.setProgress(85);
} catch (err) {
console.error('Error fetching categories:', err);
showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger');
if (pageLoader) pageLoader.fail();
}
}
async function loadMyPicks(pageLoader) {
try {
if (pageLoader) pageLoader.setProgress(70);
const res = await fetch(`/api/picks/my-picks?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok && res.status !== 404) {
throw new Error('Failed to load picks');
}
if (pageLoader) pageLoader.setProgress(80);
const data = await res.json();
if (data.pick && data.pick.picks) {
myPicks = {};
// Create a map of existing category numbers
const existingCategoryNumbers = new Set(categories.map(c => c.categoryNumber));
// Only load picks for categories that still exist
data.pick.picks.forEach(p => {
if (existingCategoryNumbers.has(p.categoryNumber)) {
myPicks[p.categoryNumber] = p.selectedNominee;
}
});
renderCategories();
updateProgress();
}
} catch (err) {
console.error('Error loading picks:', err);
}
}
function renderCategories() {
const container = document.getElementById('categories-container');
if (!container) return;
if (categories.length === 0) {
container.innerHTML = `<div class="text-center py-4 text-muted">${t('picks.noCategoriesAvailable', 'No categories available')}</div>`;
return;
}
container.innerHTML = `
<div class="table-responsive">
<table class="table table-sm picks-table align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 50px;">#</th>
<th>${t('picks.category', 'Category')}</th>
<th style="width: 200px;">${t('picks.choice', 'Choice')}</th>
<th>${t('picks.nominees', 'Nominees')}</th>
</tr>
</thead>
<tbody>
${categories.map(cat => {
const selectedNominee = myPicks[cat.categoryNumber];
return `
<tr class="${selectedNominee ? 'has-selection' : ''}">
<td class="fw-semibold text-muted">${cat.categoryNumber}</td>
<td class="fw-semibold">${cat.categoryName}</td>
<td>
${selectedNominee ? `
<span class="selected-choice-badge badge bg-success">
<span>${selectedNominee}</span>
</span>
` : `<span class="text-muted fst-italic">${t('picks.noChoice', 'No choice')}</span>`}
</td>
<td>
<div class="d-flex flex-wrap gap-2">
${cat.nominees.map(nominee => {
const isSelected = selectedNominee === nominee.name;
return `
<span class="nominee-option badge ${isSelected ? 'bg-primary selected' : 'bg-secondary bg-opacity-50'}"
data-category="${cat.categoryNumber}"
data-nominee="${nominee.name}">
${isSelected ? '<span class="nominee-checkmark">✓</span>' : ''}
<span>${nominee.name}</span>
</span>
`;
}).join('')}
</div>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
// Add click handlers
container.querySelectorAll('.nominee-option').forEach(el => {
el.addEventListener('click', function() {
const category = parseInt(this.dataset.category);
const nominee = this.dataset.nominee;
// Toggle selection
if (myPicks[category] === nominee) {
// Deselect if clicking the same nominee
delete myPicks[category];
} else {
// Select this nominee
myPicks[category] = nominee;
}
// Re-render to update the display
renderCategories();
// Auto-save after selection change
autoSavePicks();
});
});
updateProgress();
}
async function autoSavePicks() {
// Clear any existing timeout to reset the debounce
if (autoSavePicks.timeout) {
clearTimeout(autoSavePicks.timeout);
}
// Debounce: wait for user to stop clicking before saving
autoSavePicks.timeout = setTimeout(async () => {
// Skip if already saving (will retry after current save completes)
if (isSaving) {
// Retry after a short delay
setTimeout(() => autoSavePicks(), 200);
return;
}
isSaving = true;
try {
await submitPicks(true); // Pass true for silent mode (no alert)
} catch (err) {
console.error('Auto-save failed:', err);
// Show error alert only on failure
showAlert(t('picks.errorAutoSaving', 'Error auto-saving: {error}', { error: err.message }), 'danger');
} finally {
isSaving = false;
}
}, 250); // Reduced to 250ms for better responsiveness
}
function updateProgress() {
const total = categories.length;
const completed = Object.keys(myPicks).length;
document.getElementById('picks-progress').textContent = `${completed} / ${total}`;
}
async function submitPicks(silent = false) {
const picks = Object.keys(myPicks).map(catNum => ({
categoryNumber: parseInt(catNum),
selectedNominee: myPicks[catNum]
}));
// Allow empty picks to be saved (to clear all selections)
// Only show warning in non-silent mode if user explicitly tries to submit with no picks
if (picks.length === 0 && !silent) {
// Don't block - allow saving empty picks to clear selections
}
// Skip confirmation dialog in silent mode (auto-save)
if (!silent && picks.length < categories.length) {
if (!confirm(t('picks.incompleteSelection', `You have selected only ${picks.length} out of ${categories.length} categories. Do you want to save anyway?`, { selected: picks.length, total: categories.length }))) {
return;
}
}
try {
const res = await fetch('/api/picks/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
year: currentYear,
picks
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to submit picks');
}
const data = await res.json();
if (!silent) {
showAlert(t('picks.successSaved', 'Your picks have been saved successfully!'), 'success');
}
} catch (err) {
console.error('Error submitting picks:', err);
showAlert(t('picks.errorSubmitting', 'Error submitting picks: {error}', { error: err.message }), 'danger');
}
}
// Event listeners
document.getElementById('load-my-picks')?.addEventListener('click', () => {
loadMyPicks();
showAlert(t('picks.reloaded', 'Picks reloaded'), 'info');
});
// Winners & Scores functionality (admin only)
let winnersCategories = [];
async function fetchWinnersCategories() {
try {
if (!currentYear) {
currentYear = await fetchActiveYear();
}
const res = await fetch(`/api/picks/categories?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
throw new Error('Failed to fetch categories');
}
const data = await res.json();
winnersCategories = data.categories || [];
currentYear = data.year || currentYear;
const yearEl = document.getElementById('winners-year');
if (yearEl) yearEl.textContent = currentYear;
renderWinnersCategories();
await fetchScores();
} catch (err) {
console.error('Error fetching winners categories:', err);
showAlert(t('picks.errorLoadingCategories', 'Error loading categories: {error}', { error: err.message }), 'danger');
}
}
function renderWinnersCategories() {
const container = document.getElementById('winners-container');
if (!container) return;
if (winnersCategories.length === 0) {
container.innerHTML = `<div class="alert alert-warning">${t('picks.noCategoriesFound', 'No categories found for year {year}', { year: currentYear })}</div>`;
return;
}
const winnersCount = winnersCategories.filter(cat =>
cat.nominees.some(n => n.isWinner)
).length;
const progressEl = document.getElementById('winners-progress');
if (progressEl) progressEl.textContent = `${winnersCount} / ${winnersCategories.length}`;
container.innerHTML = `
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 50px;">#</th>
<th>${t('picks.categoryHeader', 'Category')}</th>
<th style="width: 200px;">${t('picks.selectionHeader', 'Selection')}</th>
<th>${t('picks.winnerHeader', 'Winner')}</th>
</tr>
</thead>
<tbody>
${winnersCategories.map(cat => {
const winner = cat.nominees.find(n => n.isWinner);
return `
<tr class="${winner ? 'table-success' : ''}">
<td class="fw-semibold">${cat.categoryNumber}</td>
<td>${cat.categoryName}</td>
<td>
<select class="form-select form-select-sm winner-select" data-category-id="${cat._id}" data-category-number="${cat.categoryNumber}">
<option value="">—</option>
${cat.nominees.map(nominee => `
<option value="${nominee.name}" ${nominee.isWinner ? 'selected' : ''}>${nominee.name}</option>
`).join('')}
</select>
</td>
<td>
${winner ? `<span class="badge bg-success">${winner.name}</span>` : '<span class="text-muted">—</span>'}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
// Add event listeners for winner selection
container.querySelectorAll('.winner-select').forEach(select => {
select.addEventListener('change', async function() {
const categoryId = this.dataset.categoryId;
const winnerName = this.value;
try {
// If empty value, clear the winner (set to null/empty)
const res = await fetch(`/api/categories/${categoryId}/winner`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ winnerName: winnerName || null })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.message || 'Failed to update winner');
}
showAlert(
winnerName
? t('picks.winnerMarked', 'Winner marked successfully')
: t('picks.winnerDeleted', 'Winner deleted'),
'success'
);
await fetchWinnersCategories();
// Auto-refresh scores after marking/clearing a winner (silent mode)
await calculateScores(true);
} catch (err) {
console.error('Error updating winner:', err);
showAlert(t('picks.errorUpdatingWinner', 'Error: {error}', { error: err.message }), 'danger');
}
});
});
}
async function calculateScores(silent = false) {
try {
if (!currentYear) {
currentYear = await fetchActiveYear();
}
const btn = document.getElementById('calculate-scores');
if (btn) {
btn.disabled = true;
btn.textContent = t('picks.calculatingScores', 'Calculating scores…');
}
const res = await fetch('/api/picks/calculate-scores', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ year: currentYear })
});
if (!res.ok) {
const error = await res.json();
// If no winners marked, handle gracefully (especially for auto-refresh)
if (error.message && error.message.includes('No winners marked')) {
if (!silent) {
// Only show as info/warning, not error
showAlert(t('picks.noWinnersMarked', 'No winners marked. Scores will be calculated once winners are defined.'), 'info');
}
// Still try to fetch existing scores
await fetchScores();
return;
}
throw new Error(error.message || 'Failed to calculate scores');
}
const data = await res.json();
if (!silent) {
showAlert(
t('picks.scoresCalculated', 'Scores calculated successfully! {count} user(s) evaluated.', { count: data.scores.length }),
'success'
);
}
await fetchScores();
} catch (err) {
console.error('Error calculating scores:', err);
if (!silent) {
showAlert(t('picks.errorCalculatingScores', 'Error: {error}', { error: err.message }), 'danger');
}
} finally {
const btn = document.getElementById('calculate-scores');
if (btn) {
btn.disabled = false;
btn.textContent = t('picks.calculateScores', 'Calculate scores');
}
}
}
async function fetchScores() {
try {
if (!currentYear) {
currentYear = await fetchActiveYear();
}
// Update scores year display
const scoresYearEl = document.getElementById('scores-year');
if (scoresYearEl) {
scoresYearEl.textContent = currentYear;
}
const res = await fetch(`/api/picks/scores?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({ message: 'Failed to fetch scores' }));
throw new Error(errorData.message || 'Failed to fetch scores');
}
const data = await res.json();
renderScores(data.scores || [], data.totalCategories || 0);
} catch (err) {
console.error('Error fetching scores:', err);
const container = document.getElementById('scores-container');
if (container) {
container.innerHTML = `<div class="text-danger">${t('picks.errorLoadingScores', 'Error loading scores.')}</div>`;
}
}
}
function renderScores(scores, totalCategories) {
const container = document.getElementById('scores-container');
if (!container) return;
// Filter out scores for removed users (Unknown users or null userId)
let validScores = scores.filter(score =>
score.userName &&
score.userName !== 'Unknown' &&
score.userId
);
// Calculate actual scores and sort by them
validScores = validScores.map(score => {
// Always calculate actual score from pickDetails if available (more accurate than stored score)
let actualScore;
if (score.pickDetails && Array.isArray(score.pickDetails) && score.pickDetails.length > 0) {
// Calculate from pickDetails - this is the source of truth
actualScore = score.pickDetails.filter(p => p.isCorrect === true).length;
} else {
// Fallback to stored score only if pickDetails not available
actualScore = score.score || 0;
}
return { ...score, actualScore };
}).sort((a, b) => b.actualScore - a.actualScore);
if (validScores.length === 0) {
container.innerHTML = `<div class="text-muted">${t('picks.noScoresAvailable', 'No scores available. Users must first submit their picks.')}</div>`;
return;
}
container.innerHTML = `
<div class="table-responsive">
<table class="table table-sm table-striped table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 40px;">#</th>
<th>${t('picks.userHeader', 'User')}</th>
<th class="text-center" style="width: 80px;">Score</th>
<th class="text-center" style="width: 70px;">Total</th>
<th class="text-center" style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
${validScores.map((score, index) => {
// Use the pre-calculated actualScore (always use it, even if 0)
const actualScore = score.actualScore !== undefined ? score.actualScore : (score.score || 0);
const rank = index + 1;
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
return `
<tr>
<td><strong>${rank}${medal ? ' ' + medal : ''}</strong></td>
<td>${score.userName}</td>
<td class="text-center"><strong class="text-success">${actualScore}</strong></td>
<td class="text-center text-muted small">${totalCategories}</td>
<td class="text-center">
<button class="btn btn-sm btn-outline-primary view-picks-btn" data-user-id="${score.userId}" data-user-name="${score.userName}" data-score-index="${index}">
${t('picks.detailsButton', 'Details')}
</button>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
// Store valid scores data globally for modal access
window.scoresData = validScores;
// Add event listeners for view picks buttons
container.querySelectorAll('.view-picks-btn').forEach(btn => {
btn.addEventListener('click', function() {
const scoreIndex = parseInt(this.dataset.scoreIndex);
const score = validScores[scoreIndex];
showUserPicksModal(score);
});
});
}
function showUserPicksModal(score) {
const modal = new bootstrap.Modal(document.getElementById('userPicksModal'));
const modalTitle = document.getElementById('userPicksModalLabel');
const modalContent = document.getElementById('user-picks-modal-content');
modalTitle.textContent = t('picks.userChoicesTitle', 'Picks for {user}', { user: score.userName });
if (!score.pickDetails || score.pickDetails.length === 0) {
modalContent.innerHTML = `<div class="text-muted">${t('picks.noDetailsAvailable', 'No details available.')}</div>`;
modal.show();
return;
}
// Separate correct and incorrect picks
const correctPicks = score.pickDetails.filter(p => p.isCorrect);
const incorrectPicks = score.pickDetails.filter(p => !p.isCorrect);
// Calculate actual score from pickDetails (more accurate than stored score)
const actualScore = correctPicks.length;
const totalCategories = score.totalCategories || score.pickDetails.length;
modalContent.innerHTML = `
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 class="mb-1">${t('picks.summaryTitle', 'Summary')}</h6>
<div class="text-muted small">
<span class="text-success"><strong>${correctPicks.length}</strong> ${t('picks.correctCountLabel', 'correct')}</span> |
<span class="text-danger"><strong>${incorrectPicks.length}</strong> ${t('picks.incorrectCountLabel', 'incorrect')}</span> |
<span class="text-muted"><strong>${score.pickDetails.length}</strong> ${t('picks.totalCountLabel', 'total')}</span>
</div>
</div>
<div class="text-end">
<div class="h4 mb-0 text-success">${actualScore} / ${totalCategories}</div>
<div class="text-muted small">${totalCategories > 0 ? Math.round((actualScore / totalCategories) * 100) : 0}%</div>
</div>
</div>
</div>
${correctPicks.length > 0 ? `
<div class="mb-4">
<h6 class="text-success mb-3">
<span class="badge bg-success me-2">✓</span>
${t('picks.correctSectionTitle', 'Correct ({count})', { count: correctPicks.length })}
</h6>
<div class="list-group">
${correctPicks.map(pick => `
<div class="list-group-item list-group-item-success">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${pick.categoryNumber}. ${pick.categoryName}</strong>
<div class="mt-1">
<span class="badge bg-success">✓ ${pick.selectedNominee}</span>
</div>
</div>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
${incorrectPicks.length > 0 ? `
<div class="mb-4">
<h6 class="text-danger mb-3">
<span class="badge bg-danger me-2">✗</span>
${t('picks.incorrectSectionTitle', 'Incorrect ({count})', { count: incorrectPicks.length })}
</h6>
<div class="list-group">
${incorrectPicks.map(pick => `
<div class="list-group-item list-group-item-danger">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<strong>${pick.categoryNumber}. ${pick.categoryName}</strong>
<div class="mt-2">
<div class="mb-1">
<span class="badge bg-danger">✗ ${t('picks.choiceLabel', 'Choice')}: ${pick.selectedNominee}</span>
</div>
${pick.correctWinner ? `
<div>
<span class="badge bg-success">✓ ${t('picks.winnerLabel', 'Winner')}: ${pick.correctWinner}</span>
</div>
` : `<div class="text-muted small">${t('picks.noWinnerMarkedShort', 'No winner marked')}</div>`}
</div>
</div>
</div>
</div>
`).join('')}
</div>
</div>
` : ''}
${correctPicks.length === 0 && incorrectPicks.length === 0 ? `
<div class="alert alert-info">
${t('picks.noChoicesToShow', 'No picks to show.')}
</div>
` : ''}
`;
modal.show();
}
// Event listeners for winners tab
document.getElementById('refresh-winners')?.addEventListener('click', () => {
fetchWinnersCategories();
showAlert(t('picks.dataRefreshed', 'Data refreshed'), 'info');
});
// Load scores when scores tab is shown
const scoresTab = document.getElementById('tab-scores');
if (scoresTab) {
scoresTab.addEventListener('shown.bs.tab', async () => {
await fetchScores();
});
}
// Load winners when admin tab is shown
const winnersTab = document.getElementById('tab-winners');
if (winnersTab) {
winnersTab.addEventListener('shown.bs.tab', async () => {
await fetchWinnersCategories();
// Auto-refresh scores when entering the winners tab (silent mode)
await calculateScores(true);
});
}
// Refresh scores button
document.getElementById('refresh-scores')?.addEventListener('click', async () => {
await fetchScores();
showAlert(t('picks.scoresRefreshed', 'Scores refreshed'), 'info');
});
// Initial load
window.addEventListener('DOMContentLoaded', async function() {
const pageLoader = createPageLoader({
title: t('picks.loadingTitle', 'Loading picks'),
subtitle: t('picks.loadingSubtitle', 'Fetching data…')
});
try {
currentYear = await fetchActiveYear();
document.title = t('picks.pageTitleChoicesWithYear', 'Oscar Pool ({year}) - My picks', { year: currentYear });
pageLoader.setProgress(12);
await fetchCategories(pageLoader);
// Auto-refresh scores on page load if scores tab is active
const scoresTab = document.getElementById('tab-scores');
const scoresPane = document.getElementById('pane-scores');
if (scoresTab && scoresPane && (scoresTab.classList.contains('active') || scoresPane.classList.contains('active'))) {
await fetchScores();
}
// Auto-refresh winners on page load if admin and winners tab is active
if (decoded.admin) {
const winnersTab = document.getElementById('tab-winners');
const winnersPane = document.getElementById('pane-winners');
// Check if winners tab is active (either by default or if it becomes active)
if (winnersTab && winnersPane && (winnersTab.classList.contains('active') || winnersPane.classList.contains('active'))) {
await fetchWinnersCategories();
await calculateScores(true); // Silent mode for auto-refresh
}
}
pageLoader.done();
} catch (err) {
console.error('Error during initial load:', err);
pageLoader.fail();
}
});
})();
File diff suppressed because it is too large Load Diff
+98 -6
View File
@@ -1,8 +1,91 @@
// Check if the user is already logged in by looking for auth_token in localStorage function decodeJwt(token) {
if (localStorage.getItem('auth_token')) { return JSON.parse(atob(token.split('.')[1]));
// If the token is found, redirect to /movies.html }
window.location.href = '/movies.html';
function translateApiMessage(messageKey, message, fallbackKey, fallbackText) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
if (messageKey) return window.i18n.t(messageKey);
const map = {
'Mauvais nom de chien': 'auth.dogAnswerIncorrect',
"L'utilisateur s'est enregistré avec succès !": 'auth.registrationSuccess',
'User registered successfully!': 'auth.registrationSuccess',
};
if (message && map[message]) return window.i18n.t(map[message]);
if (fallbackKey) return window.i18n.t(fallbackKey);
} }
} catch (_) {}
return message || fallbackText || '';
}
async function applyActiveOscarYearToTitle() {
const base = 'Pool Oscars';
const current = String(document.title || base);
const sep = ' - ';
const idx = current.indexOf(sep);
const suffix = idx >= 0 ? current.slice(idx) : '';
try {
const res = await fetch('/api/settings/year', { method: 'GET' });
const data = res.ok ? await res.json().catch(() => null) : null;
const year = Number(data?.year);
document.title = Number.isInteger(year) ? `${base} (${year})${suffix}` : `${base}${suffix}`;
} catch (_) {
document.title = `${base}${suffix}`;
}
}
// Fire and forget.
applyActiveOscarYearToTitle();
// Check setup status
async function checkSetupStatus() {
try {
const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' });
if (!res.ok) return { needsSetup: false };
return await res.json();
} catch (_) {
return { needsSetup: false };
}
}
async function initPage() {
const setupNeededCard = document.getElementById('setup-needed-card');
const registerCard = document.getElementById('register-card');
// Check if setup is needed
const status = await checkSetupStatus();
if (status.needsSetup) {
// Show setup-needed banner, hide registration form
if (setupNeededCard) setupNeededCard.classList.remove('d-none');
if (registerCard) registerCard.classList.add('d-none');
return;
}
// Check if the user is already logged in by looking for auth_token in localStorage
const existingToken = localStorage.getItem('auth_token');
if (existingToken) {
try {
const decoded = decodeJwt(existingToken);
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) {
localStorage.removeItem('auth_token');
} else if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
} else {
window.location.href = '/movies.html';
return;
}
} catch (_) {
localStorage.removeItem('auth_token');
}
}
}
// Initialize the page
initPage();
// Handle registration form submission // Handle registration form submission
document.getElementById('register-form').addEventListener('submit', async function (e) { document.getElementById('register-form').addEventListener('submit', async function (e) {
@@ -25,7 +108,12 @@ if (localStorage.getItem('auth_token')) {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
// Show success message in the modal // Show success message in the modal
responseMessageElement.textContent = data.message; responseMessageElement.textContent = translateApiMessage(
data?.messageKey,
data?.message,
'auth.registrationSuccess',
'User registered successfully!'
);
// Show the modal // Show the modal
modal.show(); modal.show();
@@ -37,7 +125,11 @@ if (localStorage.getItem('auth_token')) {
} else { } else {
const error = await res.json(); const error = await res.json();
// Show error message in the modal // Show error message in the modal
responseMessageElement.textContent = `Error: ${error.message}`; const prefix = (window.i18n && typeof window.i18n.t === 'function')
? window.i18n.t('common.errorPrefix')
: 'Error:';
const msg = translateApiMessage(error?.messageKey, error?.message, 'common.errorOccurred', 'An error occurred');
responseMessageElement.textContent = `${prefix} ${msg}`.trim();
// Show the modal // Show the modal
modal.show(); modal.show();
+169
View File
@@ -0,0 +1,169 @@
// Theme toggle with system preference fallback.
(function () {
const STORAGE_KEY = 'theme_preference';
const LAST_KEY = 'theme_last_applied';
const THEME_ATTR = 'data-theme';
const SOURCE_ATTR = 'data-theme-source';
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const API_ENDPOINT = '/api/users/theme';
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
function normalizeTheme(value) {
return value === 'light' || value === 'dark' ? value : null;
}
function getSystemTheme() {
return mediaQuery.matches ? 'dark' : 'light';
}
function setTheme(theme, source) {
document.documentElement.setAttribute(THEME_ATTR, theme);
document.documentElement.setAttribute(SOURCE_ATTR, source);
try {
sessionStorage.setItem(LAST_KEY, theme);
} catch (_) {
// Ignore storage errors.
}
updateToggleUi(theme, source);
}
function getAuthToken() {
try {
return localStorage.getItem('auth_token');
} catch (_) {
return null;
}
}
async function savePreferenceToServer(themePreference) {
const token = getAuthToken();
if (!token) return;
try {
await fetch(API_ENDPOINT, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ themePreference: themePreference ?? null })
});
} catch (_) {
// Ignore network errors to avoid blocking UI.
}
}
async function syncPreferenceFromServer() {
const token = getAuthToken();
if (!token) return;
try {
const res = await fetch(API_ENDPOINT, {
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-store'
});
if (!res.ok) return;
const data = await res.json();
const pref = normalizeTheme(data?.themePreference);
const currentPref = normalizeTheme(localStorage.getItem(STORAGE_KEY));
const changed = pref !== currentPref;
if (changed) {
if (pref) {
localStorage.setItem(STORAGE_KEY, pref);
} else {
localStorage.removeItem(STORAGE_KEY);
}
applyStoredOrSystem();
}
} catch (_) {
// Ignore fetch errors.
}
}
function applyStoredOrSystem() {
const stored = normalizeTheme(localStorage.getItem(STORAGE_KEY));
let last = null;
try {
last = normalizeTheme(sessionStorage.getItem(LAST_KEY));
} catch (_) {
last = null;
}
const theme = stored || last || getSystemTheme();
const source = stored ? 'user' : (last ? 'session' : 'system');
setTheme(theme, source);
}
function toggleTheme() {
const stored = normalizeTheme(localStorage.getItem(STORAGE_KEY));
const current = stored || getSystemTheme();
const next = current === 'dark' ? 'light' : 'dark';
localStorage.setItem(STORAGE_KEY, next);
setTheme(next, 'user');
savePreferenceToServer(next);
}
function resetToSystem() {
localStorage.removeItem(STORAGE_KEY);
applyStoredOrSystem();
savePreferenceToServer(null);
}
function updateToggleUi(theme, source) {
const buttons = document.querySelectorAll('[data-theme-toggle]');
const isDark = theme === 'dark';
buttons.forEach((button) => {
button.setAttribute('aria-checked', String(isDark));
button.dataset.theme = theme;
const label = isDark
? t('theme.darkMode', 'Dark mode')
: t('theme.lightMode', 'Light mode');
const sourceLabel = source === 'system'
? t('theme.systemSuffix', ' (system)')
: '';
const hint = t('theme.systemHint', '— Shift+click for system');
button.setAttribute('title', `${label}${sourceLabel} ${hint}`.trim());
});
}
applyStoredOrSystem();
syncPreferenceFromServer();
mediaQuery.addEventListener('change', () => {
const stored = normalizeTheme(localStorage.getItem(STORAGE_KEY));
if (!stored) {
applyStoredOrSystem();
}
});
function bindToggleButtons() {
document.querySelectorAll('[data-theme-toggle]').forEach((button) => {
if (button.dataset.themeBound === 'true') return;
button.dataset.themeBound = 'true';
button.addEventListener('click', (event) => {
if (event.shiftKey) {
resetToSystem();
return;
}
toggleTheme();
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindToggleButtons);
} else {
bindToggleButtons();
}
})();
+745 -46
View File
@@ -1,11 +1,365 @@
window.onload = async function () { function createPageLoader(options = {}) {
const title = String(options.title || t('common.loading', 'Loading…'));
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || t('stats.preparingPage', 'Preparing page…'));
let progress = 0;
let removed = false;
let navResizeObserver = null;
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
const preloadedOverlay = document.getElementById('page-loader-overlay');
const overlay = preloadedOverlay || document.createElement('div');
if (!preloadedOverlay) {
overlay.className = 'page-loader-overlay';
overlay.setAttribute('role', 'status');
overlay.setAttribute('aria-live', 'polite');
overlay.setAttribute('aria-busy', 'true');
overlay.innerHTML = `
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">${title}</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar"></div>
</div>
</div>
`;
} else {
// Keep screen reader label up to date.
const label = overlay.querySelector('.visually-hidden');
if (label) label.textContent = title;
}
const barEl = () => overlay.querySelector('#page-loader-bar');
function clampPct(p) {
const n = Number(p);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function setProgress(p) {
progress = clampPct(p);
const bar = barEl();
if (bar) bar.style.width = `${progress}%`;
}
function setSubtitle(_) {
// Intentionally no-op (we only show the bar now).
}
function getNavHeightPx() {
const nav = document.querySelector('nav.navbar');
if (!nav) return 0;
const rect = nav.getBoundingClientRect();
const h = Number(rect?.height) || 0;
return h > 0 ? Math.round(h) : 0;
}
function updateOverlayTopOffset() {
// Scope the offset to this overlay instance (no global CSS var).
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
}
function ensureMounted() {
if (removed) return;
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
// we still need to compute the navbar offset. Otherwise it defaults to 0px
// and the blur covers the header.
updateOverlayTopOffset();
document.body.classList.add('page-loading');
if (!overlay.isConnected) document.body.appendChild(overlay);
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
if (!navResizeObserver) {
const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') {
try {
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
navResizeObserver.observe(nav);
} catch (_) {
navResizeObserver = null;
}
}
}
}
function hideAndRemoveSoon() {
if (removed) return;
overlay.classList.add('page-loader-hide');
overlay.setAttribute('aria-busy', 'false');
document.body.classList.remove('page-loading');
window.setTimeout(() => {
removed = true;
if (navResizeObserver) {
try { navResizeObserver.disconnect(); } catch (_) {}
navResizeObserver = null;
}
try { overlay.remove(); } catch (_) {}
}, 220);
}
function done() {
setProgress(100);
hideAndRemoveSoon();
}
function fail(_) {
// Don't block the UI on errors: let the page render its own error message.
setProgress(Math.max(progress, 95));
window.setTimeout(() => hideAndRemoveSoon(), 200);
}
ensureMounted();
setProgress(8);
return { setProgress, setSubtitle, done, fail };
}
function getLocale() {
return (window.i18n && typeof window.i18n.getLanguage === 'function')
? window.i18n.getLanguage()
: (document.documentElement.lang || 'en');
}
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: t('stats.loadingStats', 'Loading statistics'),
subtitle: t('stats.fetchingData', 'Fetching data…')
});
const token = localStorage.getItem('auth_token'); const token = localStorage.getItem('auth_token');
let winnersCache = null;
let completionsCache = null;
async function fetchActiveYear() {
try {
const res = await fetch('/api/settings/year', { method: 'GET', cache: 'no-cache' });
if (!res.ok) throw new Error('Failed to fetch active year');
const data = await res.json();
const year = Number(data?.year);
return Number.isInteger(year) ? year : null;
} catch (_) {
return null;
}
}
async function fetchWinners() {
try {
const res = await fetch('/api/settings/winners', { method: 'GET', cache: 'no-cache' });
if (!res.ok) throw new Error('Failed to fetch winners');
const data = await res.json().catch(() => ({}));
return Array.isArray(data?.winners) ? data.winners : [];
} catch (_) {
return [];
}
}
async function fetchCompletions() {
try {
const res = await fetch('/api/users/completions', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-cache',
});
if (!res.ok) throw new Error('Failed to fetch completions');
const data = await res.json().catch(() => ({}));
return data && typeof data === 'object' ? data : null;
} catch (_) {
return null;
}
}
function renderWinners(winners) {
const tbody = document.getElementById('winners-table-body');
if (!tbody) return;
const list = Array.isArray(winners) ? winners : [];
if (!list.length) {
tbody.innerHTML = `
<tr>
<td colspan="2" class="text-muted">${t('stats.noWinnersDefined', 'No winners defined.')}</td>
</tr>
`;
return;
}
// Group by year (supports ties)
const byYear = new Map();
list.forEach((w) => {
const y = Number(w?.year || 0);
if (!Number.isInteger(y) || y < 1900 || y > 3000) return;
const arr = byYear.get(String(y)) || [];
arr.push({
year: y,
name: w?.name || t('stats.deletedUser', '(deleted user)'),
points: w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points),
});
byYear.set(String(y), arr);
});
const years = Array.from(byYear.keys()).map(Number).sort((a, b) => b - a);
tbody.innerHTML = '';
years.forEach((year) => {
const winnersForYear = byYear.get(String(year)) || [];
const sorted = winnersForYear.slice().sort((a, b) => {
const ap = a.points === null || Number.isNaN(a.points) ? null : a.points;
const bp = b.points === null || Number.isNaN(b.points) ? null : b.points;
if (ap === null && bp !== null) return 1;
if (ap !== null && bp === null) return -1;
if (ap !== null && bp !== null && ap !== bp) return bp - ap;
return String(a.name || '').localeCompare(String(b.name || ''), 'fr', { sensitivity: 'base' });
});
const tr = document.createElement('tr');
const tdYear = document.createElement('td');
tdYear.className = 'fw-semibold';
tdYear.textContent = String(year);
if (sorted.length > 1) {
const small = document.createElement('div');
small.className = 'text-muted small';
small.textContent = t('stats.tie', 'tie');
tdYear.appendChild(document.createElement('br'));
tdYear.appendChild(small);
}
const tdWinners = document.createElement('td');
sorted.forEach((w, idx) => {
const row = document.createElement('div');
if (idx > 0) row.classList.add('mt-1');
const name = document.createElement('span');
name.className = 'fw-semibold';
name.textContent = w?.name || t('stats.deletedUser', '(deleted user)');
row.appendChild(name);
const pts = w.points === null || Number.isNaN(w.points) ? null : w.points;
if (pts !== null) {
const ptsSpan = document.createElement('span');
ptsSpan.className = 'text-muted';
ptsSpan.textContent = ` — ${pts} pts`;
row.appendChild(ptsSpan);
}
tdWinners.appendChild(row);
});
tr.appendChild(tdYear);
tr.appendChild(tdWinners);
tbody.appendChild(tr);
});
}
function renderCompletions(completions) {
const tbody = document.getElementById('completers-table-body');
if (!tbody) return;
if (!completions || typeof completions !== 'object') {
tbody.innerHTML = `
<tr>
<td colspan="3" class="text-muted">${t('stats.unableToLoadCompleters', 'Unable to load 100% completers.')}</td>
</tr>
`;
return;
}
const years = Array.isArray(completions?.years) ? completions.years : [];
const totals = completions?.totals && typeof completions.totals === 'object' ? completions.totals : {};
const byYear = completions?.completersByYear && typeof completions.completersByYear === 'object' ? completions.completersByYear : {};
if (!years.length) {
tbody.innerHTML = `
<tr>
<td colspan="3" class="text-muted">${t('stats.noYearsFound', 'No years found.')}</td>
</tr>
`;
return;
}
const sortedYears = years
.map((y) => Number(y))
.filter((y) => Number.isInteger(y))
.sort((a, b) => b - a);
tbody.innerHTML = '';
sortedYears.forEach((y) => {
const yearStr = String(y);
const total = Number(totals?.[yearStr] ?? 0);
const completers = Array.isArray(byYear?.[yearStr]) ? byYear[yearStr] : [];
const tr = document.createElement('tr');
const tdYear = document.createElement('td');
tdYear.className = 'fw-semibold';
tdYear.textContent = yearStr;
const tdTotal = document.createElement('td');
tdTotal.textContent = String(Number.isFinite(total) ? total : 0);
const tdNames = document.createElement('td');
if (!completers.length) {
tdNames.className = 'text-muted';
tdNames.textContent = '—';
} else {
const wrap = document.createElement('div');
wrap.className = 'd-flex flex-wrap gap-1';
completers.forEach((u, idx) => {
const rank = idx + 1;
const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
const badge = document.createElement('span');
badge.className = 'badge bg-light text-dark border';
// Show rank, medal (if top 3), and name
const rankPrefix = medal ? `${medal} ` : `#${rank} `;
// Format completion date if available
let dateStr = '';
if (u?.completedAt) {
const date = new Date(u.completedAt);
dateStr = ` (${date.toLocaleDateString(getLocale())})`;
}
badge.textContent = `${rankPrefix}${u?.name || t('stats.unnamed', '(unnamed)')}${dateStr}`;
wrap.appendChild(badge);
});
tdNames.appendChild(wrap);
}
tr.appendChild(tdYear);
tr.appendChild(tdTotal);
tr.appendChild(tdNames);
tbody.appendChild(tr);
});
}
if (token) { if (token) {
try { try {
const decoded = JSON.parse(atob(token.split('.')[1])); // Manually decoding JWT const decoded = JSON.parse(atob(token.split('.')[1])); // Manually decoding JWT
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
const userName = decoded.name; const userName = decoded.name;
document.getElementById('user-name').textContent = userName; document.getElementById('user-name').textContent = userName;
if (decoded.admin) {
const adminLink = document.getElementById('admin-control-link');
if (adminLink) adminLink.classList.remove('d-none');
}
// Verify JWT expiration // Verify JWT expiration
const currentTime = Math.floor(Date.now() / 1000); const currentTime = Math.floor(Date.now() / 1000);
@@ -24,71 +378,416 @@ window.onload = async function () {
} }
try { try {
const statsRes = await fetch('/api/users/stats', { pageLoader.setProgress(12);
method: 'GET',
headers: { // Modal UI (watched movies)
'Authorization': `Bearer ${token}` const moviesModalEl = document.getElementById('moviesModal');
const movieGridEl = document.getElementById('movie-grid');
const moviesSummaryEl = document.getElementById('movies-summary');
const moviesEmptyEl = document.getElementById('movies-empty');
const moviesModalTitleEl = document.getElementById('moviesModalLabel');
let currentModalUserName = '';
let currentModalMovies = [];
function safeStr(v) {
return (v === null || v === undefined) ? '' : String(v);
}
function parseMaybeNumber(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function formatWatchedDate(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
try {
return d.toLocaleDateString(getLocale(), { year: 'numeric', month: 'short', day: 'numeric' });
} catch (_) {
return d.toLocaleDateString();
}
}
function normalizePosterUrl(poster) {
const s = safeStr(poster).trim();
if (!s || s.toUpperCase() === 'N/A') return '';
return s;
}
function sortMoviesRecent(list) {
const arr = Array.isArray(list) ? list.slice() : [];
// watchedDate desc, fallback title
arr.sort((a, b) => {
const at = a?.watchedDate ? new Date(a.watchedDate).getTime() : 0;
const bt = b?.watchedDate ? new Date(b.watchedDate).getTime() : 0;
if (Number.isFinite(at) && Number.isFinite(bt) && at !== bt) return bt - at;
return safeStr(a?.title).localeCompare(safeStr(b?.title), 'fr', { sensitivity: 'base' });
});
return arr;
}
function renderMoviesModal() {
if (!movieGridEl || !moviesSummaryEl || !moviesEmptyEl) return;
const sorted = sortMoviesRecent((Array.isArray(currentModalMovies) ? currentModalMovies : []));
const total = Array.isArray(currentModalMovies) ? currentModalMovies.length : 0;
moviesSummaryEl.textContent = total
? t('stats.filmCount', '{count} movie{plural}', { count: total, plural: total > 1 ? 's' : '' })
: t('stats.noFilms', '0 movies');
movieGridEl.innerHTML = '';
if (!sorted.length) {
moviesEmptyEl.classList.remove('d-none');
return;
}
moviesEmptyEl.classList.add('d-none');
sorted.forEach((movie) => {
const movieId = safeStr(movie?.movieId).trim();
const imdbId = safeStr(movie?.imdb_id).trim();
const title = safeStr(movie?.title).trim() || '(sans titre)';
const category = safeStr(movie?.category).trim();
const ratingRaw = safeStr(movie?.rating).trim();
const rating = parseMaybeNumber(ratingRaw);
const watchedLabel = formatWatchedDate(movie?.watchedDate);
const posterUrl = normalizePosterUrl(movie?.poster);
const cleanUrl = (v) => {
const s = (typeof v === 'string' ? v.trim() : '');
if (!s || s === '#' || s.toLowerCase() === 'about:blank') return '';
return s;
};
// Click behavior (requested):
// - If vod_link is set => go to it
// - Else if any playable source exists => go to player
// - Else => not clickable
const vodLink = cleanUrl(movie?.vod_link);
const hasPlayableSource = !!cleanUrl(movie?.video_src) || !!cleanUrl(movie?.embed_src) || !!cleanUrl(movie?.video_file);
const isClickable = !!vodLink || (!!movieId && hasPlayableSource);
const item = document.createElement('div');
item.className = 'stats-movie-item';
if (isClickable) {
item.classList.add('stats-movie-item--clickable');
item.setAttribute('role', 'link');
item.setAttribute('tabindex', '0');
item.setAttribute('aria-label', vodLink ? t('stats.openLink', 'Open link') + ': ' + title : t('stats.openPlayer', 'Open player') + ': ' + title);
}
const poster = document.createElement('img');
poster.className = 'stats-movie-poster';
poster.alt = title;
if (posterUrl) {
poster.src = posterUrl;
} else {
poster.classList.add('stats-movie-poster--empty');
// Keep src empty; browser will not request anything.
poster.src = '';
}
poster.addEventListener('error', () => {
poster.classList.add('stats-movie-poster--empty');
try { poster.removeAttribute('src'); } catch (_) {}
});
function openIfPossible(evt) {
if (!isClickable) return;
// Don't hijack clicks inside controls (IMDb button, details summary, etc.)
const target = evt?.target;
if (target && typeof target.closest === 'function') {
if (target.closest('a, button, summary, details')) return;
}
if (vodLink) {
// Use a new tab (keeps the app state intact)
try {
const w = window.open(vodLink, '_blank', 'noopener,noreferrer');
if (!w) window.location.href = vodLink;
} catch (_) {
window.location.href = vodLink;
}
return;
}
if (movieId && hasPlayableSource) {
window.location.href = `/player.html?id=${encodeURIComponent(movieId)}`;
}
}
if (isClickable) {
item.addEventListener('click', openIfPossible);
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openIfPossible(e);
} }
}); });
}
const main = document.createElement('div');
main.className = 'flex-grow-1';
const topRow = document.createElement('div');
topRow.className = 'd-flex align-items-start justify-content-between gap-2';
const titleEl = document.createElement('div');
titleEl.className = 'stats-movie-title';
titleEl.textContent = title;
const right = document.createElement('div');
right.className = 'text-nowrap';
if (imdbId) {
const imdbLink = document.createElement('a');
imdbLink.className = 'btn btn-outline-dark btn-sm stats-movie-imdb';
imdbLink.href = `https://www.imdb.com/title/${encodeURIComponent(imdbId)}/`;
imdbLink.target = '_blank';
imdbLink.rel = 'noopener noreferrer';
imdbLink.setAttribute('aria-label', (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('movies.viewOnImdb') : 'View on IMDb');
const imdbIcon = document.createElement('span');
imdbIcon.className = 'imdb-icon';
imdbIcon.setAttribute('aria-hidden', 'true');
imdbIcon.textContent = 'IMDb';
imdbLink.appendChild(imdbIcon);
right.appendChild(imdbLink);
}
topRow.appendChild(titleEl);
topRow.appendChild(right);
const meta = document.createElement('div');
meta.className = 'stats-movie-meta';
if (rating !== null && rating > 0) {
const r = document.createElement('span');
r.className = 'text-muted';
r.textContent = `⭐ ${rating.toFixed(1)}`;
meta.appendChild(r);
} else if (ratingRaw) {
const r = document.createElement('span');
r.className = 'text-muted';
r.textContent = `⭐ ${ratingRaw}`;
meta.appendChild(r);
}
if (watchedLabel) {
const d = document.createElement('span');
d.className = 'text-muted';
d.textContent = t('stats.watchedOn', 'Watched on {date}', { date: watchedLabel });
meta.appendChild(d);
}
main.appendChild(topRow);
if (meta.childNodes.length) main.appendChild(meta);
// Optional: keep long strings (like category) out of the main layout.
if (category) {
const details = document.createElement('details');
details.className = 'stats-movie-details';
const summary = document.createElement('summary');
summary.textContent = t('common.details', 'Details');
details.appendChild(summary);
const body = document.createElement('div');
body.className = 'stats-movie-details-body text-muted small';
body.textContent = category;
details.appendChild(body);
main.appendChild(details);
}
item.appendChild(poster);
item.appendChild(main);
movieGridEl.appendChild(item);
});
}
function openMoviesModalForUser(userStat) {
currentModalUserName = safeStr(userStat?.name).trim();
currentModalMovies = Array.isArray(userStat?.watchedMovies) ? userStat.watchedMovies : [];
if (moviesModalTitleEl) {
const count = currentModalMovies.length;
moviesModalTitleEl.textContent = currentModalUserName
? t('stats.watchedMoviesUser', 'Watched movies — {user} ({count})', { user: currentModalUserName, count: count })
: t('stats.watchedMovies', 'Watched movies ({count})', { count: count });
}
renderMoviesModal();
if (moviesModalEl) {
const modal = new bootstrap.Modal(moviesModalEl);
modal.show();
}
}
if (moviesModalEl) {
moviesModalEl.addEventListener('shown.bs.modal', () => {
// no controls to focus
});
}
// Show loading states immediately (avoid "blank" panels)
const table = document.querySelector('.user-table');
const statsError = document.getElementById('stats-error');
if (table) table.classList.add('d-none');
if (statsError) statsError.classList.add('d-none');
pageLoader.setProgress(20);
const activeYear = await fetchActiveYear();
if (activeYear) {
document.title = `Pool Oscars (${activeYear}) - ${t('stats.pageTitle', 'User Statistics')}`;
// Keep the main heading clean; show the year in the "Classement" tab instead.
const h2 = document.querySelector('h2');
if (h2) h2.textContent = t('stats.pageTitle', 'User Statistics');
const badge = document.getElementById('stats-active-year-badge');
if (badge) {
badge.textContent = String(activeYear);
badge.classList.remove('d-none');
}
}
const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats';
pageLoader.setProgress(32);
const [statsRes, winners, completions] = await Promise.all([
fetch(statsUrl, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
cache: 'no-cache',
}),
fetchWinners(),
fetchCompletions(),
]);
winnersCache = winners;
completionsCache = completions;
renderWinners(winnersCache);
renderCompletions(completionsCache);
if (!statsRes.ok) { if (!statsRes.ok) {
throw new Error('Failed to fetch user stats'); throw new Error('Failed to fetch user stats');
} }
pageLoader.setProgress(70);
const stats = await statsRes.json(); const stats = await statsRes.json();
const userTableBody = document.getElementById('user-table-body'); const userTableBody = document.getElementById('user-table-body');
const table = document.querySelector('.user-table');
const spinner = document.getElementById('loading-spinner');
// Ensure watchedRatio is treated as a number and sort in descending order // Check visibility config for Bon picks column
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio)); let showBonPicksColumn = false;
try {
const visibilityRes = await fetch('/api/settings/visibility-config', { cache: 'no-store' });
if (visibilityRes.ok) {
const visibilityConfig = await visibilityRes.json();
showBonPicksColumn = visibilityConfig.showBonPicksColumn !== false;
}
} catch (err) {
console.error('Error loading visibility config:', err);
}
// Hide/show Bon picks header
const bonPicksHeader = document.getElementById('bon-picks-header');
if (bonPicksHeader) {
bonPicksHeader.style.display = showBonPicksColumn ? '' : 'none';
}
// Sort by total points (descending), then by last watched date (earlier = better rank)
stats.sort((a, b) => {
const pointsDiff = (b.totalPoints || 0) - (a.totalPoints || 0);
if (pointsDiff !== 0) return pointsDiff;
// Tiebreaker: earlier completion wins (user who finished first ranks higher)
const aDate = a.lastWatchedAt ? new Date(a.lastWatchedAt).getTime() : Infinity;
const bDate = b.lastWatchedAt ? new Date(b.lastWatchedAt).getTime() : Infinity;
if (aDate !== bDate) return aDate - bDate;
// Final tiebreaker: alphabetical
return String(a.name || '').localeCompare(String(b.name || ''), 'fr', { sensitivity: 'base' });
});
// Clear previous rows to prevent duplication // Clear previous rows to prevent duplication
userTableBody.innerHTML = ''; userTableBody.innerHTML = '';
stats.forEach(userStat => { // Get total movies count (same for all users in the same year)
const totalMovies = stats.length > 0 ? (stats[0].totalMoviesCount || 0) : 0;
// Update Films column header with total
const filmsHeader = document.getElementById('films-header');
if (filmsHeader && totalMovies > 0) {
const filmsLabel = (window.i18n && typeof window.i18n.t === 'function') ? window.i18n.t('stats.films') : 'Movies';
filmsHeader.innerHTML = `${filmsLabel} <small class="text-muted" style="font-weight: normal;">(${totalMovies})</small>`;
}
stats.forEach((userStat, index) => {
const userRow = document.createElement('tr'); const userRow = document.createElement('tr');
userRow.innerHTML = ` const rank = index + 1;
<td> const medal = rank === 1 ? '🥇' : rank === 2 ? '🥈' : rank === 3 ? '🥉' : '';
<a href="#" class="user-link" data-movies='${JSON.stringify(userStat.watchedMovies)}'>${userStat.name}</a>
</td> const rankTd = document.createElement('td');
<td>${userStat.watchedCount}</td> rankTd.innerHTML = `<strong>${rank}${medal ? ' ' + medal : ''}</strong>`;
<td>${userStat.watchedRatio}</td>
`; const nameTd = document.createElement('td');
const link = document.createElement('a');
link.href = '#';
link.className = 'user-link';
link.textContent = userStat.name;
link.addEventListener('click', function (event) {
event.preventDefault();
openMoviesModalForUser(userStat);
});
nameTd.appendChild(link);
const countTd = document.createElement('td');
const pointsConfig = userStat.pointsConfig || { pointsPerMovie: 1 };
countTd.innerHTML = `${userStat.watchedCount ?? 0} <small class="text-muted">(${userStat.moviePoints || 0} pts)</small>`;
const picksTd = document.createElement('td');
const pickPointsConfig = userStat.pointsConfig || { pointsPerCorrectPick: 1 };
picksTd.innerHTML = `${userStat.correctPicks || 0} <small class="text-muted">(${userStat.pickPoints || 0} pts)</small>`;
if (!showBonPicksColumn) {
picksTd.style.display = 'none';
}
const pointsTd = document.createElement('td');
pointsTd.className = 'text-center';
pointsTd.innerHTML = `<strong class="text-success">${userStat.totalPoints || 0}</strong>`;
userRow.appendChild(rankTd);
userRow.appendChild(nameTd);
userRow.appendChild(countTd);
userRow.appendChild(picksTd);
userRow.appendChild(pointsTd);
userTableBody.appendChild(userRow); userTableBody.appendChild(userRow);
}); });
// Hide spinner and show table after loading // Show table after loading
spinner.style.display = 'none'; if (table) table.classList.remove('d-none');
table.style.display = 'table'; if (statsError) statsError.classList.add('d-none');
pageLoader.setProgress(92);
// Add event listener for user links pageLoader.done();
document.querySelectorAll('.user-link').forEach(link => {
link.addEventListener('click', function (event) {
event.preventDefault();
const datasetMovies = this.dataset.movies;
console.log(datasetMovies);
const movies = JSON.parse(datasetMovies);
// Clear the current movie list in the modal
const movieList = document.getElementById('movie-list');
movieList.innerHTML = '';
// Populate the modal with the movies the user has watched
movies.forEach(movie => {
const movieItem = document.createElement('li');
movieItem.classList.add('list-group-item');
movieItem.textContent = movie.title;
movieList.appendChild(movieItem);
});
// Show the modal
const modal = new bootstrap.Modal(document.getElementById('moviesModal'));
modal.show();
});
});
} catch (error) { } catch (error) {
console.error('Error loading user statistics:', error); console.error('Error loading user statistics:', error);
const table = document.querySelector('.user-table');
const statsError = document.getElementById('stats-error');
if (table) table.classList.add('d-none');
if (statsError) {
statsError.textContent = t('stats.unableToLoadRanking', 'Unable to load the ranking right now.');
statsError.classList.remove('d-none');
}
// Still attempt to render these sections even if stats fail (best effort)
try {
if (winnersCache === null) winnersCache = await fetchWinners();
renderWinners(winnersCache);
} catch (_) {}
try {
if (completionsCache === null) completionsCache = await fetchCompletions();
renderCompletions(completionsCache);
} catch (_) {}
pageLoader.fail();
} }
// Log-off functionality // Log-off functionality
@@ -96,4 +795,4 @@ window.onload = async function () {
localStorage.removeItem('auth_token'); localStorage.removeItem('auth_token');
window.location.href = '/'; window.location.href = '/';
}); });
}; });
+13 -1
View File
@@ -3,8 +3,20 @@
const res = await fetch('/api/version'); const res = await fetch('/api/version');
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (window.i18n && typeof window.i18n.init === 'function') {
try {
await window.i18n.init();
} catch (_) {}
}
const hasI18n = window.i18n && typeof window.i18n.t === 'function';
let message = data.message || '';
if (data && data.configured === false) {
if (hasI18n) {
message = window.i18n.t('footer.versionNotConfigured');
}
}
// Update footer with the fetched version info // Update footer with the fetched version info
const footerText = `${data.date} - ${data.version} - ${data.message}`; const footerText = `${data.date} - ${data.version} - ${message}`;
document.getElementById('footer-text').textContent = footerText; document.getElementById('footer-text').textContent = footerText;
} else { } else {
document.getElementById('footer-text').textContent = ''; document.getElementById('footer-text').textContent = '';
+212
View File
@@ -0,0 +1,212 @@
(function() {
'use strict';
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/';
return;
}
function decodeJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch {
return null;
}
}
const decoded = decodeJwt(token);
if (!decoded) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
// Check if admin
if (!decoded.admin) {
window.location.href = '/picks.html';
return;
}
// Logout
const logOffBtn = document.getElementById('log-off');
if (logOffBtn) {
logOffBtn.addEventListener('click', () => {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
}
let allPicks = [];
let categories = [];
let currentYear = new Date().getFullYear();
function t(key, fallback, params) {
try {
if (window.i18n && typeof window.i18n.t === 'function') {
return window.i18n.t(key, params || {});
}
} catch (_) {}
let out = (typeof fallback === 'string') ? fallback : String(key);
if (params && typeof params === 'object') {
Object.keys(params).forEach((k) => {
out = out.replace(new RegExp(`\\{${k}\\}`, 'g'), String(params[k]));
});
}
return out;
}
function getLocale() {
return (window.i18n && typeof window.i18n.getLanguage === 'function')
? window.i18n.getLanguage()
: (document.documentElement.lang || 'en');
}
function showAlert(message, type = 'info') {
const container = document.getElementById('alert-container');
if (!container) return;
container.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
setTimeout(() => {
const alert = container.querySelector('.alert');
if (alert) {
const bsAlert = new bootstrap.Alert(alert);
bsAlert.close();
}
}, 5000);
}
async function fetchAllPicks() {
try {
const res = await fetch(`/api/picks/all?year=${currentYear}`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
if (res.status === 403) {
window.location.href = '/picks.html';
return;
}
throw new Error('Failed to fetch picks');
}
const data = await res.json();
allPicks = data.picks || [];
categories = data.categories || [];
currentYear = data.year || currentYear;
document.getElementById('current-year').textContent = currentYear;
document.getElementById('users-count').textContent = allPicks.length;
renderPicks();
} catch (err) {
console.error('Error fetching picks:', err);
showAlert(t('viewPicks.errorLoading', 'Error loading picks: {error}', { error: err.message }), 'danger');
}
}
function renderPicks() {
const container = document.getElementById('picks-container');
if (!container) return;
if (allPicks.length === 0) {
container.innerHTML = `
<div class="alert alert-info">
${t('viewPicks.noPicks', 'No user has submitted picks for year {year} yet.', { year: currentYear })}
</div>
`;
return;
}
// Create a map of category info
const categoryMap = new Map();
categories.forEach(cat => {
categoryMap.set(cat.categoryNumber, cat);
});
container.innerHTML = allPicks.map(pick => {
const userName = pick.userId?.name || t('viewPicks.unknownUser', 'Unknown user');
const userEmail = pick.userId?.email || '';
let submittedDate = '—';
if (pick.submittedAt) {
const d = new Date(pick.submittedAt);
if (!Number.isNaN(d.getTime())) {
try {
submittedDate = d.toLocaleString(getLocale(), { dateStyle: 'medium', timeStyle: 'short' });
} catch (_) {
submittedDate = d.toLocaleString();
}
}
}
// Create a map of picks by category
const picksMap = new Map();
pick.picks.forEach(p => {
picksMap.set(p.categoryNumber, p.selectedNominee);
});
// Build picks list
const picksList = categories.map(cat => {
const selected = picksMap.get(cat.categoryNumber);
if (!selected) return null;
// Check if it's a winner (if winners are set)
const nominee = cat.nominees.find(n => n.name === selected);
const isWinner = nominee?.isWinner || false;
return `
<div class="pick-item ${isWinner ? 'correct-pick' : ''}">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${cat.categoryNumber}. ${cat.categoryName}</strong><br>
<span class="text-muted">${selected}</span>
</div>
${isWinner ? `<span class="badge bg-success">${t('viewPicks.winnerBadge', 'Winner')}</span>` : ''}
</div>
</div>
`;
}).filter(Boolean).join('');
return `
<div class="card user-picks-card shadow-sm">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<div>
<h5 class="mb-0">${userName}</h5>
<small class="text-muted">${userEmail}</small>
</div>
<div class="text-end">
<small class="text-muted">${t('viewPicks.submittedOn', 'Submitted on: {date}', { date: submittedDate })}</small><br>
<span class="badge bg-primary">${t('viewPicks.categoriesCount', '{count} / {total} categories', { count: pick.picks.length, total: categories.length })}</span>
</div>
</div>
</div>
<div class="card-body">
${picksList || `<div class="text-muted">${t('viewPicks.noPicks2', 'No picks recorded')}</div>`}
</div>
</div>
`;
}).join('');
}
// Event listeners
document.getElementById('refresh-picks')?.addEventListener('click', () => {
fetchAllPicks();
showAlert(t('viewPicks.picksRefreshed', 'Picks refreshed'), 'info');
});
// Initial load
fetchAllPicks();
})();
+306
View File
@@ -0,0 +1,306 @@
/**
* Lightweight i18n module for Oscar Pool
* Supports English (base) and French translations
*/
(function() {
'use strict';
const STORAGE_KEY = 'app_language';
const DEFAULT_LANG = 'en';
const SUPPORTED_LANGS = ['en', 'fr'];
let currentLang = DEFAULT_LANG;
let translations = {};
let loadedLangs = new Set();
let initPromise = null;
/**
* Detect language from: localStorage → browser → default
*/
function detectLanguage() {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && SUPPORTED_LANGS.includes(stored)) {
return stored;
}
const browserLang = (navigator.language || navigator.userLanguage || '').split('-')[0].toLowerCase();
if (SUPPORTED_LANGS.includes(browserLang)) {
return browserLang;
}
return DEFAULT_LANG;
}
/**
* Load translation file for a language
*/
async function loadTranslations(lang) {
if (loadedLangs.has(lang)) return true;
try {
const res = await fetch(`/locales/${lang}.json`, { cache: 'default' });
if (!res.ok) {
console.warn(`i18n: Failed to load ${lang}.json (${res.status})`);
return false;
}
const data = await res.json();
translations[lang] = data;
loadedLangs.add(lang);
return true;
} catch (err) {
console.warn(`i18n: Error loading ${lang}.json`, err);
return false;
}
}
/**
* Translate a key with optional interpolation
* @param {string} key - Translation key (e.g., 'nav.logout')
* @param {Object} params - Optional parameters for interpolation
* @returns {string} - Translated string or key if not found
*/
function t(key, params = {}) {
if (!key) return '';
// Try current language, then fallback to default
let text = translations[currentLang]?.[key];
if (text === undefined && currentLang !== DEFAULT_LANG) {
text = translations[DEFAULT_LANG]?.[key];
}
if (text === undefined) {
// Return key as fallback (helps identify missing translations)
return key;
}
// Simple interpolation: {varName} → value
if (params && typeof params === 'object') {
Object.keys(params).forEach(k => {
const regex = new RegExp(`\\{${k}\\}`, 'g');
text = text.replace(regex, String(params[k]));
});
}
return text;
}
/**
* Set the current language
*/
function setLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) {
console.warn(`i18n: Unsupported language "${lang}"`);
return false;
}
currentLang = lang;
localStorage.setItem(STORAGE_KEY, lang);
document.documentElement.lang = lang;
return true;
}
/**
* Get the current language
*/
function getLanguage() {
return currentLang;
}
/**
* Get list of supported languages
*/
function getSupportedLanguages() {
return [...SUPPORTED_LANGS];
}
/**
* Translate all elements with data-i18n attributes
*/
function translatePage() {
// Translate text content
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (!key) return;
// Check for interpolation params
const paramsAttr = el.getAttribute('data-i18n-params');
let params = {};
if (paramsAttr) {
try {
params = JSON.parse(paramsAttr);
} catch (_) {
// Ignore invalid JSON
}
}
const translated = t(key, params);
if (translated !== key) {
el.textContent = translated;
}
});
// Translate placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.placeholder = translated;
}
});
// Translate aria-labels
document.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
const key = el.getAttribute('data-i18n-aria-label');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.setAttribute('aria-label', translated);
}
});
// Translate titles
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.title = translated;
}
});
// Translate option values in selects
document.querySelectorAll('option[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (!key) return;
const translated = t(key);
if (translated !== key) {
el.textContent = translated;
}
});
// Mark body as ready (for CSS transitions)
document.body.classList.add('i18n-ready');
}
/**
* Update a single element's translation (for dynamic content)
*/
function translateElement(el, key, params = {}) {
if (!el || !key) return;
el.textContent = t(key, params);
}
/**
* Fetch language setting from server (optional, for admin-set language)
*/
async function fetchServerLanguage() {
try {
const res = await fetch('/api/settings/language', {
method: 'GET',
cache: 'no-store'
});
if (res.ok) {
const data = await res.json();
if (data.language && SUPPORTED_LANGS.includes(data.language)) {
return data.language;
}
}
} catch (_) {
// Silently fail - use local detection
}
return null;
}
/**
* Initialize i18n system
* @param {string} forceLang - Optional language to force (bypasses detection)
*/
async function init(forceLang = null) {
// Prevent multiple concurrent initializations
if (initPromise) return initPromise;
initPromise = (async () => {
// Determine language to use
let lang = forceLang;
if (!lang) {
// Try server setting first (if user is logged in and admin set a language)
const serverLang = await fetchServerLanguage();
if (serverLang) {
lang = serverLang;
} else {
lang = detectLanguage();
}
}
// Always load default language as fallback
await loadTranslations(DEFAULT_LANG);
// Load current language if different
if (lang !== DEFAULT_LANG) {
const loaded = await loadTranslations(lang);
if (!loaded) {
lang = DEFAULT_LANG;
}
}
setLanguage(lang);
translatePage();
return lang;
})();
return initPromise;
}
/**
* Change language and re-translate page
*/
async function changeLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) {
console.warn(`i18n: Unsupported language "${lang}"`);
return false;
}
// Load translations if not already loaded
if (!loadedLangs.has(lang)) {
const loaded = await loadTranslations(lang);
if (!loaded) return false;
}
setLanguage(lang);
translatePage();
return true;
}
/**
* Check if translations are loaded
*/
function isReady() {
return loadedLangs.size > 0;
}
// Expose globally
window.i18n = {
t,
init,
setLanguage,
getLanguage,
changeLanguage,
getSupportedLanguages,
translatePage,
translateElement,
loadTranslations,
fetchServerLanguage,
isReady,
DEFAULT_LANG,
SUPPORTED_LANGS
};
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => init());
} else {
// DOM already loaded
init();
}
})();
+587
View File
@@ -0,0 +1,587 @@
{
"nav.films": "Movies",
"nav.checklist": "Checklist",
"nav.stats": "Stats",
"nav.myPicks": "My picks",
"nav.admin": "Admin",
"nav.logout": "Log out",
"nav.viewAllPicks": "View All Picks",
"common.loading": "Loading…",
"common.preparingPage": "Preparing page…",
"common.errorLoading": "Error loading.",
"common.save": "Save",
"common.cancel": "Cancel",
"common.delete": "Delete",
"common.confirm": "Confirm",
"common.refresh": "Refresh",
"common.close": "Close",
"common.copy": "Copy",
"common.reset": "Reset",
"common.add": "Add",
"common.edit": "Edit",
"common.remove": "Remove",
"common.create": "Create",
"common.actions": "Actions",
"common.yes": "Yes",
"common.no": "No",
"common.all": "All",
"common.none": "None",
"common.search": "Search",
"common.filter": "Filter",
"common.year": "Year",
"common.name": "Name",
"common.email": "Email",
"common.status": "Status",
"common.date": "Date",
"common.title": "Title",
"common.description": "Description",
"common.networkError": "Network error",
"common.errorOccurred": "An error occurred",
"common.errorPrefix": "Error:",
"common.saving": "Saving...",
"common.success": "Success",
"common.toggleDarkMode": "Toggle dark mode",
"common.details": "Details",
"common.oscarPool": "Oscar Pool",
"auth.login": "Login",
"auth.register": "Register",
"auth.email": "Email",
"auth.password": "Password",
"auth.name": "Name",
"auth.noAccount": "No account?",
"auth.hasAccount": "Already have an account?",
"auth.signUp": "Sign up",
"auth.signIn": "Sign in",
"auth.forgotPassword": "Forgot password?",
"auth.emailPlaceholder": "[email protected]",
"auth.namePlaceholder": "Your name",
"auth.passwordMin": "Minimum {min} characters",
"auth.passwordsDoNotMatch": "Passwords do not match",
"auth.confirmPassword": "Confirm password",
"auth.createAdminAccount": "Create admin account",
"auth.firstTimeSetup": "First time setup",
"auth.noAdminExists": "No administrator exists. Create the first admin account to get started.",
"auth.registrationStatus": "Registration status",
"auth.returnToHome": "Return to home page",
"auth.setupRequired": "Setup required",
"auth.appMustBeConfigured": "The application must first be configured by an administrator.",
"auth.goToSetup": "Go to setup page",
"auth.dogQuestion": "What is Cath & Nath's dog's name?",
"auth.errorCreatingAccount": "Error creating account",
"auth.networkErrorRetry": "Network error. Please try again.",
"auth.userNotFound": "User not found",
"auth.dogAnswerIncorrect": "Incorrect dog name.",
"auth.registrationSuccess": "User registered successfully!",
"auth.tempPasswordExpired": "Temporary password expired. Ask an admin to reset your password.",
"auth.passwordMinLength": "Password must be at least 8 characters.",
"auth.loginFailed": "Login failed",
"auth.invalidCredentials": "Invalid credentials",
"auth.tempPasswordExpired": "Temporary password expired. Ask an admin to reset your password.",
"changePassword.title": "Change Password",
"changePassword.tempPassword": "Your account uses a temporary password. Please set a new password to continue.",
"changePassword.newPassword": "New password",
"changePassword.minChars": "Minimum 8 characters.",
"changePassword.passwordTooShort": "Password must be at least 8 characters.",
"movies.title": "Movies",
"movies.lastUpdated": "Last movie update:",
"movies.watchedRatio": "Watched: {watched} / {total} ({percent}%)",
"movies.timeUntilOscars": "Time until the Oscars ({date} {year}) — {timeLeft}",
"movies.timeUntilOscarsLabel": "Time until the Oscars",
"movies.daysLeft": "{days} days",
"movies.hoursLeft": "{hours} hours",
"movies.unwatchedOnly": "Unwatched movies only",
"movies.clickToPlay": "Click the poster or title to open the player.",
"movies.watched": "WATCHED",
"movies.noSource": "No source",
"movies.nominatedCategories": "Nominated categories",
"movies.resumeAt": "Resume at {time}",
"movies.userRating": "User rating {rating}",
"movies.errorLoading": "Error loading.",
"movies.errorLoadingList": "Unable to load movie list. Try again in a moment.",
"movies.errorLoading": "Unable to load movies. Check your connection and try again.",
"movies.unableToLoadMovies": "Unable to load movies. Please try again later.",
"movies.unableToLoadMoviesNetwork": "Unable to load movies. Check your connection and try again.",
"movies.loadingMovies": "Loading movies",
"movies.fetchingData": "Fetching data…",
"movies.viewOnImdb": "View on IMDb",
"checklist.title": "Checklist",
"checklist.seen": "Seen",
"checklist.filmTitle": "Title",
"checklist.date": "Date",
"checklist.watchedOn": "Watched on:",
"checklist.confirmRemoval": "Confirm removal",
"checklist.removeConfirmText": "Are you sure you want to remove {title} from your list?",
"checklist.moviesToWatchPerDay": "Movies to watch per day: {count} films",
"checklist.congratulations": "Congratulations! very nice 🎉🎉🎉",
"checklist.browserNoVideo": "Your browser does not support video.",
"checklist.loadingChecklist": "Loading checklist",
"checklist.errorLoading": "Error loading.",
"checklist.unableToLoad": "Unable to load checklist. Please try again later.",
"checklist.unableToLoadNetwork": "Unable to load checklist. Check your connection and try again.",
"checklist.march15": "March 15",
"checklist.loadingMovies": "Loading movies…",
"picks.title": "My picks",
"picks.myPicks": "My picks",
"picks.pageTitleWithYear": "Oscar Pool ({year}) - My picks",
"picks.pageTitleChoicesWithYear": "Oscar Pool ({year}) - My picks",
"picks.scores": "Scores",
"picks.winners": "Winners",
"picks.adminBadge": "Admin",
"picks.year": "Year:",
"picks.progress": "Progress:",
"picks.reload": "Reload",
"picks.loadingCategories": "Loading categories…",
"picks.scoresCalculatedAfterWinners": "Scores will be calculated after marking winners.",
"picks.winnersMarked": "winners marked",
"picks.noCategory": "No category",
"picks.noChoice": "No choice",
"picks.category": "Category",
"picks.choice": "Choice",
"picks.nominees": "Nominees",
"picks.noCategoriesFound": "No categories found for year {year}",
"picks.adminMustImportCategories": "An administrator must first import the categories.",
"picks.selectionHeader": "Selection",
"picks.winnerHeader": "Winner",
"picks.noCategoriesAvailable": "No categories available",
"picks.winnerMarkedSuccess": "Winner marked successfully",
"picks.winnerRemoved": "Winner removed",
"picks.noWinnerMarked": "No winner marked. Scores will be calculated once winners are set.",
"picks.scoresCalculated": "Scores calculated! {count} user(s) evaluated.",
"picks.scoresRefreshed": "Scores refreshed",
"picks.calculatingScores": "Calculating scores…",
"picks.calculateScores": "Calculate scores",
"picks.dataRefreshed": "Data refreshed",
"picks.noScoresAvailable": "No scores available. Users must first submit their picks.",
"picks.details": "Details",
"picks.choicesOf": "Choices of {name}",
"picks.noDetailsAvailable": "No details available.",
"picks.summary": "Summary",
"picks.correct": "{count} correct",
"picks.incorrect": "{count} incorrect",
"picks.correctPicks": "Correct ({count})",
"picks.errorLoadingCategories": "Error loading categories: {error}",
"picks.preparingPage": "Preparing page…",
"picks.errorAutoSaving": "Error auto-saving: {error}",
"picks.incompleteSelection": "You have selected only {selected} out of {total} categories. Do you want to save anyway?",
"picks.successSaved": "Your picks have been saved successfully!",
"picks.errorSubmitting": "Error submitting picks: {error}",
"picks.reloaded": "Picks reloaded",
"picks.categoryHeader": "Category",
"picks.userHeader": "User",
"picks.winnerMarked": "Winner marked successfully",
"picks.winnerDeleted": "Winner deleted",
"picks.errorUpdatingWinner": "Error: {error}",
"picks.noWinnersMarked": "No winners marked. Scores will be calculated once winners are defined.",
"picks.scoresCalculated": "Scores calculated successfully! {count} user(s) evaluated.",
"picks.errorCalculatingScores": "Error: {error}",
"picks.errorLoadingScores": "Error loading scores.",
"picks.incorrectPicks": "Incorrect ({count})",
"picks.detailsButton": "Details",
"picks.userChoicesTitle": "Picks for {user}",
"picks.noDetailsAvailable": "No details available.",
"picks.summaryTitle": "Summary",
"picks.correctCountLabel": "correct",
"picks.incorrectCountLabel": "incorrect",
"picks.totalCountLabel": "total",
"picks.correctSectionTitle": "Correct ({count})",
"picks.incorrectSectionTitle": "Incorrect ({count})",
"picks.choiceLabel": "Choice",
"picks.winnerLabel": "Winner",
"picks.noWinnerMarkedShort": "No winner marked",
"picks.noChoicesToShow": "No picks to show.",
"picks.loadingTitle": "Loading picks",
"picks.loadingSubtitle": "Fetching data…",
"picks.yourChoice": "Choice: {choice}",
"picks.winner": "Winner: {winner}",
"picks.noWinnerMarkedYet": "No winner marked",
"picks.noPicksToShow": "No picks to show.",
"picks.partialSaveConfirm": "You have only selected {selected} out of {total} categories. Do you want to save anyway?",
"picks.savedSuccessfully": "Your picks have been saved successfully!",
"picks.errorSaving": "Error saving: {error}",
"picks.errorLoadingCategories": "Error loading categories: {error}",
"picks.autoSaveError": "Auto-save error: {error}",
"picks.picksReloaded": "Picks reloaded",
"picks.choiceDetails": "Choice details",
"picks.loadingPicks": "Loading picks",
"viewPicks.title": "View All Picks",
"viewPicks.overview": "Overview of all user picks",
"viewPicks.usersSubmitted": "{count} user(s) have submitted their picks",
"viewPicks.loadingPicks": "Loading picks…",
"viewPicks.noPicks": "No user has submitted picks for year {year} yet.",
"viewPicks.picksRefreshed": "Picks refreshed",
"viewPicks.errorLoading": "Error loading picks: {error}",
"viewPicks.unknownUser": "Unknown user",
"viewPicks.categoriesCount": "{count} / {total} categories",
"viewPicks.noPicks2": "No picks recorded",
"viewPicks.submittedOn": "Submitted on: {date}",
"viewPicks.winnerBadge": "Winner",
"player.title": "Player",
"player.loading": "Loading…",
"player.quality": "Quality",
"player.high": "High",
"player.low": "Low",
"player.rateThisFilm": "Rate this film",
"player.star": "{count} star",
"player.stars": "{count} stars",
"player.source": "Source",
"player.progress": "Progress",
"player.noVodLink": "No VOD link configured for this movie.",
"player.missingMovieId": "Missing movie id.",
"player.noPlayableSource": "No playable source configured for this movie.",
"player.beginning": "Start",
"player.resuming": "Resuming",
"player.resumingAt": "Resuming at {time}",
"player.preparingResume": "Preparing resume…",
"player.connectionRequired": "Connection required for saving.",
"player.connection": "Connection",
"player.playFromBeginning": "Playing from the beginning",
"player.sessionExpired": "Session expired. Please reconnect to continue saving progress.",
"player.saving": "Saving…",
"player.saved": "Saved at {time}.",
"player.loadingPlayer": "Loading player…",
"player.unableToIdentifyUser": "Unable to identify user for saving progress.",
"common.continueQuestion": "Continue?",
"stats.title": "Statistics",
"stats.pageTitle": "User Statistics",
"stats.ranking": "Ranking",
"stats.winners": "Winners 🏆",
"stats.completers": "100%",
"stats.rank": "#",
"stats.name": "Name",
"stats.films": "Movies",
"stats.correctPicks": "Correct picks",
"stats.points": "Points",
"stats.year": "Year",
"stats.winnersColumn": "Winner(s)",
"stats.completersHelp": "Users who have watched 100% (by year)",
"stats.noWinnersDefined": "No winners defined.",
"stats.noYearsFound": "No years found.",
"stats.unableToLoadRanking": "Unable to load the ranking right now.",
"stats.totalFilms": "Total movies",
"stats.names": "Names",
"stats.moviesWatched": "Movies watched",
"stats.noMoviesToShow": "No movies to show.",
"stats.loading": "Loading…",
"stats.preparingPage": "Preparing page…",
"stats.loadingStats": "Loading statistics",
"stats.fetchingData": "Fetching data…",
"stats.deletedUser": "(deleted user)",
"stats.tie": "tie",
"stats.unableToLoadCompleters": "Unable to load 100% completers.",
"stats.unnamed": "(unnamed)",
"stats.openLink": "Open link",
"stats.openPlayer": "Open player",
"stats.watchedOn": "Watched on {date}",
"stats.watchedMoviesUser": "Watched movies — {user} ({count})",
"stats.watchedMovies": "Watched movies ({count})",
"stats.filmCount": "{count} movie{plural}",
"stats.noFilms": "0 movies",
"admin.movies.noMovies": "No movies.",
"admin.title": "Oscar Pool - Admin",
"admin.tabs.settings": "Settings",
"admin.tabs.users": "Users",
"admin.tabs.modal100": "Modal 100%",
"admin.tabs.winners": "Winners",
"admin.tabs.oscarCategories": "Oscar Categories",
"admin.tabs.backupRestore": "Backup / Restore",
"admin.tabs.version": "Version",
"admin.settings.info": "Global site settings (year, dates, player options).",
"admin.settings.oscarDate": "Oscar Date",
"admin.settings.visibility": "Visibility",
"admin.settings.language": "Language",
"admin.settings.oscarDateDesc": "Configure the ceremony date for the countdown (Films & Checklist). Default: March 15.",
"admin.settings.oscarDateDefaultShort": "— (default: 03-15)",
"admin.settings.year": "Year",
"admin.settings.applyActiveYear": "Apply active year",
"admin.settings.yearBasedOnMovies": "Based on years present in movies.",
"admin.settings.date": "Date",
"admin.settings.dateFormat": "Format: YYYY-MM-DD.",
"admin.settings.configuredDates": "Configured dates",
"admin.settings.pointsDesc": "Configure points awarded for watched movies and correct Oscar picks.",
"admin.settings.pointsPerMovie": "Points per movie watched",
"admin.settings.pointsPerMovieDesc": "Points awarded for each completed movie.",
"admin.settings.pointsPerCorrectPick": "Points per correct pick",
"admin.settings.pointsPerCorrectPickDesc": "Points awarded for each correct Oscar pick.",
"admin.settings.reload": "Reload",
"admin.settings.pointsCalculation": "Points calculation",
"admin.settings.total": "Total",
"admin.settings.moviesWatched": "Movies watched",
"admin.settings.correctPicks": "Correct picks",
"admin.settings.pointsSaved": "Configuration saved: {p} pts/movie, {q} pts/correct pick",
"admin.settings.noYearAddMovie": "No year (add a movie)",
"admin.settings.defaultBadge": "Default",
"admin.settings.pointsMustBeNonNegative": "Points must be zero or positive",
"admin.visibility.showPicksFeatures": "Show \"Picks\" features",
"admin.visibility.showPicksFeaturesDesc": "Controls display of \"My picks\" button in navigation and \"Correct picks\" column in statistics.",
"admin.visibility.showSourceProgress": "Show \"Source\" + \"Progress\" (admins)",
"admin.visibility.showSourceProgressDesc": "Controls display of debug block in player.html (admins only).",
"admin.visibility.showVersionTab": "Show \"Version\" tab",
"admin.visibility.showVersionTabDesc": "Controls display of the \"Version\" tab in the admin panel.",
"admin.visibility.saved": "Configuration saved",
"admin.visibility.saveError": "Error saving configuration: {error}",
"admin.language.siteLanguage": "Site language",
"admin.language.siteLanguageDesc": "Changes the language for all users. Page refresh required.",
"admin.version.info": "Controls the version displayed in the footer (stored in DB). Click ✓ to activate a version, then check the preview.",
"admin.version.createNew": "Create a new version",
"admin.version.createNewDesc": "Adds an entry to the history (you can activate it immediately).",
"admin.version.version": "Version",
"admin.version.versionPlaceholder": "e.g.: 1.0.1",
"admin.version.versionExample": "E.g.: 1.2.3",
"admin.version.messageOptional": "Message (optional)",
"admin.version.messagePlaceholder": "e.g.: Hotfix login",
"admin.version.messageDesc": "Short & readable (shown in footer).",
"admin.version.create": "Create",
"admin.version.createAndActivate": "Create & activate",
"admin.version.created": "Version created.",
"admin.version.createdAndActivated": "Version created & activated.",
"admin.version.confirmDelete": "Delete this version?",
"admin.backup.chooseFile": "Choose a backup file (.ndjson.gz).",
"admin.backup.restoreWithDrop": "RESTORE with DROP: all restored collections will be replaced.",
"admin.backup.restoreWithoutDrop": "RESTORE without DROP: high risk of duplicates/errors if the DB already contains data.",
"admin.modal100.info": "Content displayed when a user has watched 100% of films.",
"admin.modal100.content": "Content",
"admin.modal100.contentDesc": "Edit the content then save. Preview is live (not saved until you save).",
"admin.modal100.titleLabel": "Title",
"admin.modal100.titlePlaceholder": "Congratulations! ...",
"admin.modal100.textOptional": "Text (optional)",
"admin.modal100.textPlaceholder": "Text displayed in the modal (line breaks preserved)",
"admin.modal100.textDesc": "Displayed as plain text (safe). Leave empty if not needed.",
"admin.modal100.htmlOptional": "HTML (optional)",
"admin.modal100.htmlDesc": "Displayed as HTML (server removes script tags and on* handlers).",
"admin.modal100.videoSrc": "Video src (optional)",
"admin.modal100.videoSrcDesc": "Relative path (e.g.: video/reward.mp4) or full URL. Empty = no video.",
"admin.modal100.browserNoVideo": "Your browser does not support video.",
"admin.modal100.textLabel": "Text",
"admin.modal100.textInfoShort": "displayed as-is (safe), line breaks preserved.",
"admin.modal100.htmlInfoShort": "server removes script tags and on* attributes.",
"admin.modal100.videoLabel": "Video",
"admin.modal100.videoInfoShort": "accepts relative path (e.g.: video/reward.mp4) or full URL.",
"admin.modal100.defaultTitle": "Congratulations! very nice 🎉🎉🎉",
"admin.users.usersCount": "user(s)",
"admin.users.addUser": "Add user",
"admin.users.name": "Name",
"admin.users.status": "Status",
"admin.users.actions": "Actions",
"admin.users.loading": "Loading list…",
"admin.users.resetPassword": "Reset password",
"admin.users.resetPasswordDesc": "Generates a temporary password. The user will need to change it on next login.",
"admin.users.tempPasswordOnce": "Temporary password (shown once)",
"admin.users.generate": "Generate",
"admin.users.deleteUser": "Delete user",
"admin.users.aboutToDelete": "You are about to delete:",
"admin.users.actionIrreversible": "This action is irreversible.",
"admin.users.typeEmailToConfirm": "To confirm, type the exact email:",
"admin.users.emailPlaceholder": "[email protected]",
"admin.users.deleteButtonActivates": "The \"Delete\" button will activate only if the email matches.",
"admin.users.addUserDesc": "Create an account with a temporary password (shown once). The user will need to change it on next login.",
"admin.users.namePlaceholder": "First name / nickname",
"admin.users.reset": "Reset",
"admin.users.cannotDeleteSelf": "You cannot delete your own account from here.",
"admin.users.adminDeleteDisabled": "Deleting admin accounts is disabled via this interface.",
"admin.users.creating": "Creating…",
"admin.users.nameRequired": "Name required.",
"admin.users.emailRequired": "Email required.",
"admin.users.userCreated": "User created. Temporary password generated.",
"admin.users.tempPasswordExpires": "Expires: {expiresAt}",
"admin.users.tempPasswordGenerating": "Generating temporary password…",
"admin.users.tempPasswordGenerated": "Temporary password generated. Copy it and send it to the user.",
"admin.users.tempPasswordGeneratedOnReset": "Temporary password generated. The user must change it on next login.",
"admin.users.cannotCopyAuto": "Unable to copy automatically. Copy manually.",
"admin.users.copied": "Copied.",
"admin.users.userDeleted": "User deleted.",
"admin.movies.idCopied": "Movie ID copied.",
"admin.movies.addedWithSubtitles": "Movie added with subtitles.",
"admin.movies.addedSuccessfully": "Movie added successfully.",
"admin.movies.addedNoSubtitles": "Movie added, but subtitles were not uploaded: {error}",
"admin.movies.updatedSuccessfully": "Movie updated successfully.",
"admin.movies.updatedNoSubtitles": "Movie updated, but subtitles were not uploaded: {error}",
"admin.language.saved": "Language saved",
"admin.movies.addInfo": "Add movies: this section auto-fetches movie info via imdb_id (OMDb).",
"admin.movies.oscarYear": "Oscar year",
"admin.movies.oscarYearDesc": "Separates movies & stats by year.",
"admin.movies.category": "Category",
"admin.movies.allYears": "All",
"admin.movies.refreshOmdb": "Refresh OMDb",
"admin.movies.selectAll": "Select all",
"admin.movies.selected": "selected",
"admin.movies.sel": "Sel.",
"admin.movies.titleCol": "Title",
"admin.movies.player": "Player",
"admin.movies.year": "Year",
"admin.movies.editMovie": "Edit movie",
"admin.movies.info": "Info",
"admin.movies.playerVideo": "Player / Video",
"admin.movies.imdbIdLabel": "IMDB ID (e.g.: tt1234567)",
"admin.movies.rating": "Rating (imdbRating)",
"admin.movies.runtime": "Runtime",
"admin.movies.runtimeExample": "E.g.: 142 min",
"admin.movies.camTag": "CAM tag",
"admin.movies.refreshFromOmdb": "Refresh from OMDb (overwrites title / plot / rating / runtime / poster)",
"admin.movies.refreshFromOmdbDesc": "If checked, the server makes a new OMDb call via IMDB ID when saving.",
"admin.movies.posterUrl": "Poster URL",
"admin.movies.description": "Description (Plot)",
"admin.movies.playerSource": "Player (source)",
"admin.movies.mode": "Mode",
"admin.movies.modeDesc": "Auto = video_src → embed_src → vod_link.",
"admin.movies.videoUrl": "Video URL (mp4/webm/m3u8)",
"admin.movies.videoUrlDesc": "If you use a server file (below) and leave this empty, it will auto-use /api/video/<movieId>.",
"admin.movies.serverVideoFile": "Server video file (optional)",
"admin.movies.serverVideoFileDesc": "Relative path under VIDEO_FILES_DIR (default: public/video).",
"admin.movies.serverVideoFileLow": "Server video file (low quality, optional)",
"admin.movies.serverVideoFileLowDesc": "Relative path under VIDEO_FILES_DIR. Example: LOW/123.LOW.mp4.",
"admin.movies.subtitles": "Subtitles (SRT, optional)",
"admin.movies.existingSubtitles": "Existing subtitles:",
"admin.movies.subtitlesNote": "Add one file per language; repeat upload for multiple subtitles.",
"admin.movies.removeAllSubtitles": "Remove all subtitles",
"admin.movies.removeAllSubtitlesDesc": "Deletes VTT/SRT files and resets language/label fields.",
"admin.movies.confirmRemoveSubtitle": "Delete this subtitle?",
"admin.movies.confirmDeleteSelected": "Delete {count} movie(s)? This action is irreversible.",
"admin.movies.subtitleLang": "Language (e.g.: fr, en)",
"admin.movies.subtitleLabel": "Label (displayed)",
"admin.movies.subtitleDefault": "Default",
"admin.movies.embedUrl": "Embed URL or iframe code",
"admin.movies.legacyVodLink": "Legacy VOD link (optional)",
"admin.movies.confirmRefreshOmdb": "Refresh {count} movie(s){yearLabel} from OMDb? This operation may overwrite info (title, plot, rating, poster, runtime).",
"admin.backup.info": "MongoDB database backup and restore.",
"admin.backup.backup": "Backup",
"admin.backup.restore": "Restore",
"admin.backup.help": "Help",
"admin.backup.backups": "Backups",
"admin.backup.backupsDesc": "Format .ndjson.gz, stored on server.",
"admin.backup.createBackup": "Create backup",
"admin.backup.file": "File",
"admin.backup.size": "Size",
"admin.backup.modified": "Modified",
"admin.backup.openTabToLoad": "Open tab to load list…",
"admin.backup.restoreBackup": "Restore a backup",
"admin.backup.restoreDesc": "Restore a .ndjson.gz file. The drop option is recommended.",
"admin.backup.restoreWarning": "Restore can overwrite data. Make a backup first if needed.",
"admin.backup.backupFile": "Backup file",
"admin.backup.dropBeforeRestore": "Drop collections before restore (recommended)",
"admin.backup.tip": "Tip",
"admin.backup.tipDesc": "Use drop unless you know exactly what you're doing (otherwise risk of duplicates/errors).",
"admin.backup.howItWorks": "How it works",
"admin.backup.backupHowDesc": "creates a .ndjson.gz file (MongoDB export) and keeps it on the server.",
"admin.backup.download": "Download",
"admin.backup.downloadDesc": "retrieves the file locally (useful before a risky operation).",
"admin.backup.restoreHowDesc": "imports the file. With drop, restored collections are deleted first.",
"admin.backup.quickCheck": "Quick check",
"admin.backup.noBackups": "No backups found.",
"admin.backup.quickCheckDesc": "After restore, reload the page and check \"Films\", \"Checklist\" and \"Stats\".",
"admin.backup.confirmDelete": "Delete backup \"{name}\"?",
"admin.backup.creating": "Creating…",
"admin.backup.created": "Backup created: {name}",
"admin.backup.noDocumentsInserted": "No documents inserted (or empty backup).",
"admin.backup.noDocumentsInserted": "No documents inserted (or empty backup).",
"admin.categories.info": "Create and manage categories manually.",
"admin.categories.manualCreation": "Manual creation",
"admin.categories.categoryCreated": "Category created successfully.",
"admin.categories.list": "List",
"admin.categories.createManually": "Create a category manually",
"admin.categories.createManuallyDesc": "Add a category and its nominees one by one.",
"admin.categories.categoryNumber": "Category number",
"admin.categories.categoryName": "Category name",
"admin.categories.nomineesOnePerLine": "Nominees (one per line)",
"admin.categories.nomineesDesc": "Enter one nominee per line.",
"admin.categories.note": "Note",
"admin.categories.noteDesc": "You can create multiple categories for the same year. The category number must be unique per year.",
"admin.categories.importedCategories": "Imported categories",
"admin.categories.manageExisting": "Manage existing categories",
"admin.categories.allYears": "All years",
"admin.categories.categoriesSelected": "category(ies) selected",
"admin.categories.editCategory": "Edit category",
"admin.categories.selectAllTitle": "Select all",
"admin.categories.table.number": "#",
"admin.categories.table.name": "Name",
"admin.categories.table.nominees": "Nominees",
"admin.categories.table.actions": "Actions",
"admin.categories.confirmDelete": "Are you sure you want to delete this category?",
"admin.categories.confirmDeleteMultiple": "Are you sure you want to delete {count} category(ies)?\n\n{names}\n\nThis action is irreversible.",
"admin.categories.noneFound": "No categories found.",
"admin.categories.noneFoundYear": "No categories found for year {year}.",
"admin.categories.fillAllFields": "Please fill in all fields",
"admin.categories.enterAtLeastOneNominee": "Please enter at least one nominee",
"admin.categories.numberBetween1And50": "Category number must be between 1 and 50",
"admin.categories.bulkDeleteConfirm": "Are you sure you want to delete {count} category(ies)?\n\n{names}\n\nThis action is irreversible.",
"admin.winners.info": "Define one or more winners per year (tie possible).",
"admin.winners.manage": "Manage",
"admin.winners.addWinner": "Add a winner",
"admin.winners.addWinnerDesc": "You can add multiple winners for the same year (tie).",
"admin.winners.user": "User",
"admin.winners.removeNote": "To remove: use the list below.",
"admin.winners.pointsOptional": "Points (optional)",
"admin.winners.pointsPlaceholder": "e.g.: 42",
"admin.winners.pointsDesc": "Leave empty if not applicable.",
"admin.winners.deleteAll": "Delete all",
"admin.winners.currentWinners": "Current winners",
"admin.winners.globalView": "Global view (all years).",
"admin.winners.howItWorks": "How it works",
"admin.winners.multipleWinners": "A year can have multiple winners (tie).",
"admin.winners.tieLabel": "(tie)",
"admin.winners.tie": "(tie)",
"admin.winners.chooseUser": "Choose a user.",
"admin.winners.confirmDeleteAll": "Delete all winners for {year}?",
"admin.player.errorLoading": "Unable to load player settings.",
"admin.versions.updated": "Version updated.",
"admin.versions.deleted": "Version deleted.",
"admin.winners.pointsOptionalInfo": "Points are optional (display only).",
"admin.winners.removeInfo": "To remove a winner, use the \"Remove\" button in the \"Current winners\" list.",
"admin.winners.tipDesc": "If you change the site's active year, also check the winners for that year.",
"admin.winners.removedFor": "Winner removed for {year}.",
"admin.winners.reloaded": "Winners reloaded.",
"admin.winners.deletedFor": "Winner deleted for {year}.",
"admin.winners.addedFor": "Winner added for {year}.",
"admin.backup.deleted": "Backup deleted.",
"admin.backup.restoreComplete": "Restore complete.",
"admin.movies.deletedSuccessfully": "Movie(s) deleted successfully.",
"admin.movies.updatedFromOmdb": "{count} movie(s) updated from OMDb.",
"admin.movies.omdbResults": "{success} success, {errors} error(s). {details}",
"admin.movies.subtitleDeleted": "Subtitle deleted.",
"admin.modal100.saved": "100% modal saved.",
"admin.content.reloaded": "Content reloaded.",
"admin.oscars.invalidYear": "Invalid year. Expected: 2026",
"admin.oscars.dateSaved": "Oscars date saved for {year}: {date}",
"admin.oscars.dateCleared": "Oscars date cleared for {year} (reverted to default: March 15).",
"admin.player.settingsSaved": "Player settings saved.",
"admin.player.settingsReloaded": "Player settings reloaded.",
"admin.activeYear.applied": "Active year applied: {year}",
"admin.configuration.reloaded": "Configuration reloaded.",
"admin.categories.categoryNotFound": "Category not found.",
"admin.categories.categoryDeleted": "Category deleted.",
"admin.categories.categoryUpdated": "Category updated successfully.",
"admin.categories.deletedCount": "{count} category/categories deleted{errors}",
"admin.oscars.invalidYearShort": "Invalid year.",
"admin.movies.subtitleDeleted": "Subtitle deleted.",
"admin.categories.categoryInvalid": "Invalid category.",
"footer.oscarPool": "Oscar Pool",
"footer.by": "by",
"footer.versionNotConfigured": "Now in full HD",
"footer.versionUnavailable": "Version unavailable (DB)."
}
+587
View File
@@ -0,0 +1,587 @@
{
"nav.films": "Films",
"nav.checklist": "Checklist",
"nav.stats": "Stats",
"nav.myPicks": "Mes picks",
"nav.admin": "Admin",
"nav.logout": "Déconnexion",
"nav.viewAllPicks": "Voir Tous les Choix",
"common.loading": "Chargement…",
"common.preparingPage": "Préparation de la page…",
"common.errorLoading": "Erreur lors du chargement.",
"common.save": "Enregistrer",
"common.cancel": "Annuler",
"common.delete": "Supprimer",
"common.confirm": "Confirmer",
"common.refresh": "Rafraîchir",
"common.close": "Fermer",
"common.copy": "Copier",
"common.reset": "Reset",
"common.add": "Ajouter",
"common.edit": "Modifier",
"common.remove": "Retirer",
"common.create": "Créer",
"common.actions": "Actions",
"common.yes": "Oui",
"common.no": "Non",
"common.all": "Tous",
"common.none": "Aucun",
"common.search": "Rechercher",
"common.filter": "Filtrer",
"common.year": "Année",
"common.name": "Nom",
"common.email": "Courriel",
"common.status": "Statut",
"common.date": "Date",
"common.title": "Titre",
"common.description": "Description",
"common.networkError": "Erreur réseau",
"common.errorOccurred": "Une erreur est survenue",
"common.errorPrefix": "Erreur :",
"common.saving": "Enregistrement...",
"common.success": "Succès",
"common.toggleDarkMode": "Basculer le mode sombre",
"common.details": "Détails",
"common.oscarPool": "Pool Oscars",
"auth.login": "Connexion",
"auth.register": "Enregistrer",
"auth.email": "Courriel",
"auth.password": "Mot de passe",
"auth.name": "Nom",
"auth.noAccount": "Pas de compte?",
"auth.hasAccount": "Déjà un compte?",
"auth.signUp": "Enregistrez-vous",
"auth.signIn": "Connectez-vous",
"auth.forgotPassword": "Mot de passe oublié?",
"auth.emailPlaceholder": "[email protected]",
"auth.namePlaceholder": "Votre nom",
"auth.passwordMin": "Minimum {min} caractères",
"auth.passwordsDoNotMatch": "Les mots de passe ne correspondent pas",
"auth.confirmPassword": "Confirmer le mot de passe",
"auth.createAdminAccount": "Créer le compte admin",
"auth.firstTimeSetup": "Première installation",
"auth.noAdminExists": "Aucun administrateur n'existe. Créez le premier compte admin pour commencer.",
"auth.registrationStatus": "Statut de l'enregistrement",
"auth.returnToHome": "Retourner à la page d'accueil",
"auth.setupRequired": "Configuration requise",
"auth.appMustBeConfigured": "L'application doit d'abord être configurée par un administrateur.",
"auth.goToSetup": "Aller à la page de configuration",
"auth.dogQuestion": "Le nom du chien à Cath & Nath?",
"auth.errorCreatingAccount": "Erreur lors de la création du compte",
"auth.networkErrorRetry": "Erreur réseau. Veuillez réessayer.",
"auth.userNotFound": "L'utilisateur n'a pas été trouvé",
"auth.dogAnswerIncorrect": "Mauvais nom de chien.",
"auth.registrationSuccess": "L'utilisateur s'est enregistré avec succès !",
"auth.loginFailed": "Échec de connexion",
"auth.invalidCredentials": "Informations d'identification non valides",
"auth.tempPasswordExpired": "Le mot de passe temporaire a expiré. Demandez à un admin de réinitialiser votre mot de passe.",
"auth.passwordMinLength": "Le mot de passe doit contenir au moins 8 caractères.",
"changePassword.title": "Changer le mot de passe",
"changePassword.tempPassword": "Votre compte utilise un mot de passe temporaire. Veuillez définir un nouveau mot de passe pour continuer.",
"changePassword.newPassword": "Nouveau mot de passe",
"changePassword.minChars": "Minimum 8 caractères.",
"changePassword.passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères.",
"movies.title": "Films",
"movies.lastUpdated": "Dernière mise à jour des films:",
"movies.watchedRatio": "Vu: {watched} / {total} ({percent}%)",
"movies.timeUntilOscars": "Temps avant les Oscars ({date} {year}) — {timeLeft}",
"movies.timeUntilOscarsLabel": "Temps avant les Oscars",
"movies.daysLeft": "{days} jours",
"movies.hoursLeft": "{hours} heures",
"movies.unwatchedOnly": "Films non regardés seulement",
"movies.clickToPlay": "Clique sur l'affiche ou le titre pour ouvrir le lecteur.",
"movies.watched": "VISIONNÉ",
"movies.noSource": "Aucune source",
"movies.nominatedCategories": "Catégories nominées",
"movies.resumeAt": "Reprendre à {time}",
"movies.userRating": "Note utilisateurs {rating}",
"movies.errorLoading": "Erreur lors du chargement.",
"movies.errorLoadingList": "Impossible de charger la liste des films. Réessaie dans quelques instants.",
"movies.errorLoading": "Impossible de charger les films. Vérifie ta connexion puis réessaie.",
"movies.unableToLoadMovies": "Impossible de charger la liste des films. Réessaie dans quelques instants.",
"movies.unableToLoadMoviesNetwork": "Impossible de charger les films. Vérifie ta connexion puis réessaie.",
"movies.loadingMovies": "Chargement des films",
"movies.fetchingData": "Récupération des données…",
"movies.viewOnImdb": "Voir sur IMDb",
"checklist.title": "Checklist",
"checklist.seen": "Vu",
"checklist.filmTitle": "Titre",
"checklist.date": "Date",
"checklist.watchedOn": "Regardé le:",
"checklist.confirmRemoval": "Confirmer le retrait",
"checklist.removeConfirmText": "Es-tu sûr de vouloir retirer {title} de ta liste?",
"checklist.moviesToWatchPerDay": "À voir par jour: {count} films",
"checklist.congratulations": "Félicitations! very nice 🎉🎉🎉",
"checklist.browserNoVideo": "Votre navigateur ne supporte pas la vidéo.",
"checklist.loadingChecklist": "Chargement de la checklist",
"checklist.errorLoading": "Erreur lors du chargement.",
"checklist.unableToLoad": "Impossible de charger la checklist. Réessaie dans quelques instants.",
"checklist.unableToLoadNetwork": "Impossible de charger la checklist. Vérifie ta connexion puis réessaie.",
"checklist.march15": "15 mars",
"checklist.loadingMovies": "Chargement des films…",
"picks.title": "Mes picks",
"picks.myPicks": "Mes picks",
"picks.pageTitleWithYear": "Pool Oscars ({year}) - Mes picks",
"picks.pageTitleChoicesWithYear": "Pool Oscars ({year}) - Mes picks",
"picks.scores": "Scores",
"picks.winners": "Gagnants",
"picks.adminBadge": "Admin",
"picks.year": "Année:",
"picks.progress": "Progression:",
"picks.reload": "Recharger",
"picks.loadingCategories": "Chargement des catégories…",
"picks.scoresCalculatedAfterWinners": "Les scores seront calculés après avoir marqué les gagnants.",
"picks.winnersMarked": "gagnants marqués",
"picks.noCategory": "Aucune catégorie",
"picks.noChoice": "Aucun choix",
"picks.category": "Catégorie",
"picks.choice": "Choix",
"picks.nominees": "Nommés",
"picks.noCategoriesFound": "Aucune catégorie trouvée pour l'année {year}",
"picks.adminMustImportCategories": "Un administrateur doit d'abord importer les catégories.",
"picks.selectionHeader": "Sélection",
"picks.winnerHeader": "Gagnant",
"picks.noCategoriesAvailable": "Aucune catégorie disponible",
"picks.winnerMarkedSuccess": "Gagnant marqué avec succès",
"picks.winnerRemoved": "Gagnant supprimé",
"picks.noWinnerMarked": "Aucun gagnant marqué. Les scores seront calculés une fois les gagnants définis.",
"picks.scoresCalculated": "Scores calculés avec succès! {count} utilisateur(s) évalué(s).",
"picks.scoresRefreshed": "Scores rafraîchis",
"picks.calculatingScores": "Calcul des scores…",
"picks.calculateScores": "Calculer les scores",
"picks.dataRefreshed": "Données rafraîchies",
"picks.noScoresAvailable": "Aucun score disponible. Les utilisateurs doivent d'abord soumettre leurs choix.",
"picks.details": "Détails",
"picks.choicesOf": "Choix de {name}",
"picks.noDetailsAvailable": "Aucun détail disponible.",
"picks.summary": "Résumé",
"picks.correct": "{count} correct(s)",
"picks.incorrect": "{count} incorrect(s)",
"picks.correctPicks": "Corrects ({count})",
"picks.errorLoadingCategories": "Erreur lors du chargement des catégories: {error}",
"picks.preparingPage": "Préparation de la page…",
"picks.errorAutoSaving": "Erreur lors de l'enregistrement automatique: {error}",
"picks.incompleteSelection": "Vous avez sélectionné que {selected} sur {total} catégories. Voulez-vous enregistrer quand même?",
"picks.successSaved": "Vos choix ont été enregistrés avec succès!",
"picks.errorSubmitting": "Erreur lors de la soumission des choix: {error}",
"picks.reloaded": "Choix rechargés",
"picks.categoryHeader": "Catégorie",
"picks.userHeader": "Utilisateur",
"picks.winnerMarked": "Gagnant marqué avec succès",
"picks.winnerDeleted": "Gagnant supprimé",
"picks.errorUpdatingWinner": "Erreur: {error}",
"picks.noWinnersMarked": "Aucun gagnant marqué. Les scores seront calculés une fois les gagnants définis.",
"picks.scoresCalculated": "Scores calculés avec succès! {count} utilisateur(s) évalué(s).",
"picks.errorCalculatingScores": "Erreur: {error}",
"picks.errorLoadingScores": "Erreur lors du chargement des scores.", "picks.incorrectPicks": "Incorrects ({count})",
"picks.detailsButton": "Détails",
"picks.userChoicesTitle": "Choix de {user}",
"picks.noDetailsAvailable": "Aucun détail disponible.",
"picks.summaryTitle": "Résumé",
"picks.correctCountLabel": "correct(s)",
"picks.incorrectCountLabel": "incorrect(s)",
"picks.totalCountLabel": "total",
"picks.correctSectionTitle": "Corrects ({count})",
"picks.incorrectSectionTitle": "Incorrects ({count})",
"picks.choiceLabel": "Choix",
"picks.winnerLabel": "Gagnant",
"picks.noWinnerMarkedShort": "Aucun gagnant marqué",
"picks.noChoicesToShow": "Aucun choix à afficher.",
"picks.loadingTitle": "Chargement des picks",
"picks.loadingSubtitle": "Récupération des données…",
"picks.yourChoice": "Choix: {choice}",
"picks.winner": "Gagnant: {winner}",
"picks.noWinnerMarkedYet": "Aucun gagnant marqué",
"picks.noPicksToShow": "Aucun choix à afficher.",
"picks.partialSaveConfirm": "Vous n'avez sélectionné que {selected} sur {total} catégories. Voulez-vous enregistrer quand même?",
"picks.savedSuccessfully": "Vos choix ont été enregistrés avec succès!",
"picks.errorSaving": "Erreur lors de l'enregistrement: {error}",
"picks.errorLoadingCategories": "Erreur lors du chargement des catégories: {error}",
"picks.autoSaveError": "Erreur lors de l'enregistrement automatique: {error}",
"picks.picksReloaded": "Choix rechargés",
"picks.choiceDetails": "Détails des choix",
"picks.loadingPicks": "Chargement des picks",
"viewPicks.title": "Voir Tous les Choix",
"viewPicks.overview": "Vue d'ensemble de tous les choix des utilisateurs",
"viewPicks.usersSubmitted": "{count} utilisateur(s) ont soumis leurs choix",
"viewPicks.loadingPicks": "Chargement des choix…",
"viewPicks.noPicks": "Aucun utilisateur n'a encore soumis ses choix pour l'année {year}.",
"viewPicks.picksRefreshed": "Choix rafraîchis",
"viewPicks.errorLoading": "Erreur lors du chargement des choix: {error}",
"viewPicks.unknownUser": "Utilisateur inconnu",
"viewPicks.categoriesCount": "{count} / {total} catégories",
"viewPicks.noPicks2": "Aucun choix enregistré",
"viewPicks.submittedOn": "Soumis le: {date}",
"viewPicks.winnerBadge": "Gagnant",
"player.title": "Lecteur",
"player.loading": "Chargement…",
"player.quality": "Qualité",
"player.high": "Haute",
"player.low": "Basse",
"player.rateThisFilm": "Noter ce film",
"player.star": "{count} étoile",
"player.stars": "{count} étoiles",
"player.source": "Source",
"player.progress": "Progress",
"player.noVodLink": "Aucun lien VOD configuré pour ce film.",
"player.missingMovieId": "ID du film manquant.",
"player.noPlayableSource": "Aucune source de lecture configurée pour ce film.",
"player.beginning": "Début",
"player.resuming": "Reprise",
"player.resumingAt": "Reprise à {time}",
"player.preparingResume": "Prépare la reprise…",
"player.connectionRequired": "Connexion requise pour sauvegarder.",
"player.connection": "Connexion",
"player.playFromBeginning": "Lecture depuis le début",
"player.sessionExpired": "Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.",
"player.beginning": "Début",
"player.resuming": "Reprise",
"player.resumingAt": "Reprise à {time}",
"player.preparingResume": "Prépare la reprise…",
"player.connectionRequired": "Connexion requise pour sauvegarder.",
"player.connection": "Connexion",
"player.playFromBeginning": "Lecture depuis le début",
"player.saving": "Sauvegarde…",
"player.saved": "Sauvegardé à {time}.",
"player.loadingPlayer": "Chargement du lecteur",
"player.unableToIdentifyUser": "Impossible d'identifier l'utilisateur pour sauvegarder la progression.",
"common.continueQuestion": "Continuer ?",
"player.connectionRequired": "Connexion requise pour sauvegarder.",
"player.connection": "Connexion",
"player.playFromBeginning": "Lecture depuis le début",
"player.sessionExpired": "Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.",
"stats.title": "Statistiques",
"stats.pageTitle": "Statistiques des utilisateurs",
"stats.ranking": "Classement",
"stats.winners": "Gagnants 🏆",
"stats.completers": "100%",
"stats.rank": "#",
"stats.name": "Nom",
"stats.films": "Films",
"stats.correctPicks": "Bon picks",
"stats.points": "Points",
"stats.year": "Année",
"stats.winnersColumn": "Gagnant(s)",
"stats.completersHelp": "Utilisateurs ayant terminé 100% (par année)",
"stats.noWinnersDefined": "Aucun gagnant défini.",
"stats.noYearsFound": "Aucune année trouvée.",
"stats.unableToLoadRanking": "Impossible de charger le classement pour le moment.",
"stats.totalFilms": "Total films",
"stats.names": "Noms",
"stats.moviesWatched": "Films regardés",
"stats.noMoviesToShow": "Aucun film à afficher.",
"stats.loading": "Chargement…",
"stats.preparingPage": "Préparation de la page…",
"stats.loadingStats": "Chargement des statistiques",
"stats.fetchingData": "Récupération des données…",
"stats.deletedUser": "(utilisateur supprimé)",
"stats.tie": "égalité",
"stats.unableToLoadCompleters": "Impossible de charger les finisseurs 100%.",
"stats.unnamed": "(sans nom)",
"stats.openLink": "Ouvrir le lien",
"stats.openPlayer": "Ouvrir le lecteur",
"stats.watchedOn": "Vu le {date}",
"stats.watchedMoviesUser": "Films regardés — {user} ({count})",
"stats.watchedMovies": "Films regardés ({count})",
"stats.filmCount": "{count} film{plural}",
"stats.noFilms": "0 films",
"admin.movies.noMovies": "Aucun film.",
"admin.title": "Pool Oscars - Admin",
"admin.tabs.settings": "Réglages",
"admin.tabs.users": "Utilisateurs",
"admin.tabs.modal100": "Modal 100%",
"admin.tabs.winners": "Gagnants",
"admin.tabs.oscarCategories": "Catégories Oscars",
"admin.tabs.backupRestore": "Backup / Restore",
"admin.tabs.version": "Version",
"admin.settings.info": "Paramètres globaux du site (année, dates, options du lecteur).",
"admin.settings.oscarDate": "Date Oscars",
"admin.settings.visibility": "Visibilité",
"admin.settings.language": "Langue",
"admin.settings.oscarDateDesc": "Configure la date de cérémonie pour le compte à rebours (Films & Checklist). Défaut: 15 mars.",
"admin.settings.oscarDateDefaultShort": "— (défaut: 03-15)",
"admin.settings.year": "Année",
"admin.settings.applyActiveYear": "Appliquer l'année active",
"admin.settings.yearBasedOnMovies": "Basé sur les années présentes dans les films.",
"admin.settings.date": "Date",
"admin.settings.dateFormat": "Format: YYYY-MM-DD.",
"admin.settings.configuredDates": "Dates configurées",
"admin.settings.pointsDesc": "Configure les points attribués pour les films regardés et les bonnes réponses aux Oscars.",
"admin.settings.pointsPerMovie": "Points par film regardé",
"admin.settings.pointsPerMovieDesc": "Points attribués pour chaque film complété.",
"admin.settings.pointsPerCorrectPick": "Points par bonne réponse",
"admin.settings.pointsPerCorrectPickDesc": "Points attribués pour chaque bonne réponse aux Oscars.",
"admin.settings.reload": "Recharger",
"admin.settings.pointsCalculation": "Calcul des points",
"admin.settings.total": "Total",
"admin.settings.moviesWatched": "Films regardés",
"admin.settings.correctPicks": "Bons picks",
"admin.settings.pointsSaved": "Configuration enregistrée: {p} pts/film, {q} pts/bonne réponse",
"admin.settings.noYearAddMovie": "Aucune année (ajoute un film)",
"admin.visibility.showPicksFeatures": "Afficher les fonctionnalités \"Picks\"",
"admin.visibility.showPicksFeaturesDesc": "Contrôle l'affichage du bouton \"Mes picks\" dans la navigation et de la colonne \"Bon picks\" dans les statistiques.",
"admin.visibility.showSourceProgress": "Afficher \"Source\" + \"Progress\" (admins)",
"admin.visibility.showSourceProgressDesc": "Contrôle l'affichage du bloc debug dans player.html (admins uniquement).",
"admin.visibility.showVersionTab": "Afficher l'onglet \"Version\"",
"admin.visibility.showVersionTabDesc": "Contrôle l'affichage de l'onglet \"Version\" dans le panneau admin.",
"admin.visibility.saved": "Configuration enregistrée",
"admin.visibility.saveError": "Erreur lors de l'enregistrement : {error}",
"admin.language.siteLanguage": "Langue du site",
"admin.language.siteLanguageDesc": "Change la langue pour tous les utilisateurs. Rafraîchissement de page requis.",
"admin.version.info": "Contrôle la version affichée dans le footer (stockée en DB). Clique sur ✓ pour activer une version, puis vérifie l'aperçu.",
"admin.version.createNew": "Créer une nouvelle version",
"admin.version.createNewDesc": "Ajoute une entrée dans l'historique (tu peux l'activer immédiatement).",
"admin.version.version": "Version",
"admin.version.versionPlaceholder": "ex: 1.0.1",
"admin.version.versionExample": "Ex: 1.2.3",
"admin.version.messageOptional": "Message (optionnel)",
"admin.version.messagePlaceholder": "ex: Hotfix login",
"admin.version.messageDesc": "Court & lisible (affiché dans le footer).",
"admin.version.create": "Créer",
"admin.version.createAndActivate": "Créer & activer",
"admin.version.created": "Version créée.",
"admin.version.createdAndActivated": "Version créée & activée.",
"admin.version.confirmDelete": "Supprimer cette version ?",
"admin.backup.chooseFile": "Choisis un fichier de sauvegarde (.ndjson.gz).",
"admin.backup.restoreWithDrop": "RESTORE avec DROP: toutes les collections restaurées seront remplacées.",
"admin.backup.restoreWithoutDrop": "RESTORE sans DROP: risque élevé de doublons/erreurs si la DB contient déjà des données.",
"admin.backup.noDocumentsInserted": "Aucun document inséré (ou backup vide).",
"admin.modal100.info": "Contenu affiché quand un utilisateur a vu 100% des films.",
"admin.modal100.content": "Contenu",
"admin.modal100.contentDesc": "Modifie le contenu puis enregistre. L'aperçu est live (non sauvegardé tant que tu n'enregistres pas).",
"admin.modal100.titleLabel": "Titre",
"admin.modal100.titlePlaceholder": "Félicitations! ...",
"admin.modal100.textOptional": "Texte (optionnel)",
"admin.modal100.textPlaceholder": "Texte affiché dans le modal (retours à la ligne conservés)",
"admin.modal100.textDesc": "Affiché en texte brut (safe). Laisse vide si tu n'en veux pas.",
"admin.modal100.htmlOptional": "HTML (optionnel)",
"admin.modal100.htmlDesc": "Affiché comme HTML (le serveur retire les <script> et les handlers on*).",
"admin.modal100.videoSrc": "Video src (optionnel)",
"admin.modal100.videoSrcDesc": "Chemin relatif (ex: video/reward.mp4) ou URL complète. Vide = pas de vidéo.",
"admin.modal100.browserNoVideo": "Votre navigateur ne supporte pas la vidéo.",
"admin.modal100.textLabel": "Texte",
"admin.modal100.textInfoShort": "affiché tel quel (safe), retours à la ligne conservés.",
"admin.modal100.htmlInfoShort": "le serveur retire les <script> et les attributs on*.",
"admin.modal100.videoLabel": "Vidéo",
"admin.modal100.videoInfoShort": "accepte un chemin relatif (ex: video/reward.mp4) ou une URL complète.",
"admin.modal100.defaultTitle": "Félicitations! very nice 🎉🎉🎉",
"admin.users.usersCount": "utilisateur(s)",
"admin.users.addUser": "Ajouter un utilisateur",
"admin.users.name": "Nom",
"admin.users.status": "Statut",
"admin.users.actions": "Actions",
"admin.users.loading": "Charge la liste…",
"admin.users.resetPassword": "Reset MDP",
"admin.users.resetPasswordDesc": "Génère un mot de passe temporaire. L'utilisateur devra le changer à sa prochaine connexion.",
"admin.users.tempPasswordOnce": "Mot de passe temporaire (affiché une seule fois)",
"admin.users.generate": "Générer",
"admin.users.deleteUser": "Supprimer un utilisateur",
"admin.users.aboutToDelete": "Tu es sur le point de supprimer:",
"admin.users.actionIrreversible": "Cette action est irréversible.",
"admin.users.typeEmailToConfirm": "Pour confirmer, tape l'email exact:",
"admin.users.emailPlaceholder": "[email protected]",
"admin.users.deleteButtonActivates": "Le bouton \"Supprimer\" s'activera seulement si l'email correspond.",
"admin.users.addUserDesc": "Crée un compte avec un mot de passe temporaire (affiché une seule fois). L'utilisateur devra le changer à sa prochaine connexion.",
"admin.users.namePlaceholder": "Prénom / pseudo",
"admin.users.reset": "Reset",
"admin.users.cannotDeleteSelf": "Impossible de supprimer ton propre compte via cette interface.",
"admin.users.adminDeleteDisabled": "La suppression des comptes admin est désactivée via cette interface.",
"admin.users.creating": "Création…",
"admin.users.nameRequired": "Nom requis.",
"admin.users.emailRequired": "Email requis.",
"admin.users.userCreated": "Utilisateur créé. Mot de passe temporaire généré.",
"admin.users.tempPasswordExpires": "Expire: {expiresAt}",
"admin.users.tempPasswordGenerating": "Génération du mot de passe temporaire…",
"admin.users.tempPasswordGenerated": "Mot de passe temporaire généré. Copie-le et envoie-le à l'utilisateur.",
"admin.users.tempPasswordGeneratedOnReset": "Mot de passe temporaire généré. L'utilisateur devra le changer à sa prochaine connexion.",
"admin.users.cannotCopyAuto": "Impossible de copier automatiquement. Copie manuellement.",
"admin.users.copied": "Copié.",
"admin.movies.idCopied": "Movie ID copié.",
"admin.movies.addedWithSubtitles": "Film ajouté avec sous-titres.",
"admin.movies.addedSuccessfully": "Film ajouté avec succès.",
"admin.movies.addedNoSubtitles": "Film ajouté, mais sous-titres non uploadés: {error}",
"admin.movies.updatedSuccessfully": "Film mis à jour avec succès.",
"admin.movies.updatedNoSubtitles": "Film mis à jour, mais sous-titres non uploadés: {error}",
"admin.language.saved": "Langue enregistrée",
"admin.movies.addInfo": "Ajout de films : cette section récupère automatiquement les infos du film via l'imdb_id (OMDb).",
"admin.movies.oscarYear": "Oscar year",
"admin.movies.oscarYearDesc": "Sépare films & stats par année.",
"admin.movies.category": "Catégorie",
"admin.movies.allYears": "Toutes",
"admin.movies.refreshOmdb": "Refresh OMDb",
"admin.movies.selectAll": "Tout sélectionner",
"admin.movies.selected": "sélectionné(s)",
"admin.movies.sel": "Sel.",
"admin.movies.titleCol": "Titre",
"admin.movies.player": "Player",
"admin.movies.year": "Année",
"admin.movies.editMovie": "Modifier le film",
"admin.movies.info": "Info",
"admin.movies.playerVideo": "Player / Vidéo",
"admin.movies.imdbIdLabel": "IMDB ID (ex: tt1234567)",
"admin.movies.rating": "Rating (imdbRating)",
"admin.movies.runtime": "Runtime",
"admin.movies.runtimeExample": "Ex: 142 min",
"admin.movies.camTag": "Tag CAM",
"admin.movies.refreshFromOmdb": "Recharger depuis OMDb (écrase titre / plot / rating / runtime / poster)",
"admin.movies.refreshFromOmdbDesc": "Si coché, le serveur refait un appel OMDb via l'IMDB ID au moment de l'enregistrement.",
"admin.movies.posterUrl": "Poster URL",
"admin.movies.description": "Description (Plot)",
"admin.movies.playerSource": "Lecteur (source)",
"admin.movies.mode": "Mode",
"admin.movies.modeDesc": "Auto = video_src → embed_src → vod_link.",
"admin.movies.videoUrl": "Video URL (mp4/webm/m3u8)",
"admin.movies.videoUrlDesc": "Si tu utilises un fichier serveur (ci-dessous) et que tu laisses vide, il utilisera auto /api/video/<movieId>.",
"admin.movies.serverVideoFile": "Fichier vidéo serveur (optionnel)",
"admin.movies.serverVideoFileDesc": "Chemin relatif sous VIDEO_FILES_DIR (défaut: public/video).",
"admin.movies.serverVideoFileLow": "Fichier vidéo serveur (basse qualité, optionnel)",
"admin.movies.serverVideoFileLowDesc": "Chemin relatif sous VIDEO_FILES_DIR. Exemple: LOW/123.LOW.mp4.",
"admin.movies.subtitles": "Sous-titres (SRT, optionnel)",
"admin.movies.existingSubtitles": "Sous-titres existants:",
"admin.movies.subtitlesNote": "Ajoute un fichier par langue; répète l'upload pour plusieurs sous-titres.",
"admin.movies.removeAllSubtitles": "Supprimer tous les sous-titres",
"admin.movies.removeAllSubtitlesDesc": "Efface les fichiers VTT/SRT et réinitialise les champs de langue/label.",
"admin.movies.confirmRemoveSubtitle": "Supprimer ce sous-titre ?",
"admin.movies.confirmDeleteSelected": "Supprimer {count} film(s) ? Cette action est irréversible.",
"admin.movies.subtitleLang": "Langue (ex: fr, en)",
"admin.movies.subtitleLabel": "Label (affiché)",
"admin.movies.subtitleDefault": "Par défaut",
"admin.movies.embedUrl": "Embed URL or iframe code",
"admin.movies.legacyVodLink": "Legacy VOD link (optionnel)",
"admin.backup.info": "Sauvegarde et restauration de la base MongoDB.",
"admin.backup.backup": "Backup",
"admin.backup.restore": "Restore",
"admin.backup.help": "Aide",
"admin.backup.backups": "Sauvegardes",
"admin.backup.backupsDesc": "Format .ndjson.gz, stocké sur le serveur.",
"admin.backup.createBackup": "Créer une sauvegarde",
"admin.backup.file": "Fichier",
"admin.backup.size": "Taille",
"admin.backup.modified": "Modifié",
"admin.backup.openTabToLoad": "Ouvre l'onglet pour charger la liste…",
"admin.backup.restoreBackup": "Restaurer une sauvegarde",
"admin.backup.restoreDesc": "Restaure un fichier .ndjson.gz. L'option drop est recommandée.",
"admin.backup.restoreWarning": "La restauration peut écraser les données. Fais un backup juste avant si besoin.",
"admin.backup.backupFile": "Fichier de backup",
"admin.backup.dropBeforeRestore": "Drop les collections avant restore (recommandé)",
"admin.backup.noBackups": "Aucune sauvegarde.",
"admin.backup.tip": "Conseil",
"admin.backup.tipDesc": "Utilise drop sauf si tu sais exactement ce que tu fais (sinon risque de doublons/erreurs).",
"admin.backup.howItWorks": "Fonctionnement",
"admin.backup.backupHowDesc": "crée un fichier .ndjson.gz (export MongoDB) et le garde sur le serveur.",
"admin.backup.download": "Télécharger",
"admin.backup.downloadDesc": "récupère le fichier localement (utile avant une manipulation risquée).",
"admin.backup.restoreHowDesc": "importe le fichier. Avec drop, les collections restaurées sont d'abord supprimées.",
"admin.backup.quickCheck": "Check rapide",
"admin.backup.quickCheckDesc": "Après restore, recharge la page et vérifie \"Films\", \"Checklist\" et \"Statistiques\".",
"admin.backup.confirmDelete": "Supprimer la sauvegarde \"{name}\" ?",
"admin.backup.creating": "Création…",
"admin.backup.created": "Sauvegarde créée : {name}",
"admin.categories.info": "Créez et gérez les catégories manuellement.",
"admin.categories.manualCreation": "Création manuelle",
"admin.categories.categoryCreated": "Catégorie créée avec succès.",
"admin.categories.list": "Liste",
"admin.categories.createManually": "Créer une catégorie manuellement",
"admin.categories.createManuallyDesc": "Ajoutez une catégorie et ses nommés un par un.",
"admin.categories.categoryNumber": "Numéro de catégorie",
"admin.categories.categoryName": "Nom de la catégorie",
"admin.categories.nomineesOnePerLine": "Nommés (un par ligne)",
"admin.categories.nomineesDesc": "Entrez un nommé par ligne.",
"admin.categories.note": "Note",
"admin.categories.noteDesc": "Vous pouvez créer plusieurs catégories pour la même année. Le numéro de catégorie doit être unique par année.",
"admin.categories.importedCategories": "Catégories importées",
"admin.categories.manageExisting": "Gérez les catégories existantes",
"admin.categories.allYears": "Toutes les années",
"admin.categories.categoriesSelected": "catégorie(s) sélectionnée(s)",
"admin.categories.editCategory": "Modifier la catégorie",
"admin.categories.selectAllTitle": "Sélectionner tout",
"admin.categories.table.number": "#",
"admin.categories.table.name": "Nom",
"admin.categories.table.nominees": "Nommés",
"admin.categories.table.actions": "Actions",
"admin.categories.confirmDelete": "Êtes-vous sûr de vouloir supprimer cette catégorie?",
"admin.categories.confirmDeleteMultiple": "Êtes-vous sûr de vouloir supprimer {count} catégorie(s)?\n\n{names}\n\nCette action est irréversible.",
"admin.categories.noneFound": "Aucune catégorie trouvée.",
"admin.categories.noneFoundYear": "Aucune catégorie trouvée pour l'année {year}.",
"admin.winners.info": "Définis un ou plusieurs gagnants par année (égalité possible).",
"admin.winners.manage": "Gérer",
"admin.winners.addWinner": "Ajouter un gagnant",
"admin.winners.addWinnerDesc": "Tu peux ajouter plusieurs gagnants pour une même année (égalité).",
"admin.winners.user": "Utilisateur",
"admin.winners.removeNote": "Pour retirer: utilise la liste ci-dessous.",
"admin.winners.pointsOptional": "Points (optionnel)",
"admin.winners.pointsPlaceholder": "ex: 42",
"admin.winners.pointsDesc": "Laisse vide si non applicable.",
"admin.winners.deleteAll": "Tout supprimer",
"admin.winners.currentWinners": "Gagnants actuels",
"admin.winners.globalView": "Vue globale (toutes les années).",
"admin.winners.howItWorks": "Comment ça marche",
"admin.winners.multipleWinners": "Une année peut avoir plusieurs gagnants (égalité).",
"admin.winners.tieLabel": "(égalité)",
"admin.winners.tie": "(égalité)",
"admin.winners.chooseUser": "Choisis un utilisateur.",
"admin.winners.confirmDeleteAll": "Supprimer tous les gagnants pour {year} ?",
"admin.player.errorLoading": "Impossible de charger les réglages du lecteur.",
"admin.versions.updated": "Version mise à jour.",
"admin.versions.deleted": "Version supprimée.",
"admin.winners.pointsOptionalInfo": "Les points sont optionnels (affichage uniquement).",
"admin.winners.removeInfo": "Pour retirer un gagnant, utilise le bouton \"Retirer\" dans la liste \"Gagnants actuels\".",
"admin.winners.tipDesc": "Si tu changes l'année active du site, vérifie aussi les gagnants de cette année.",
"admin.winners.removedFor": "Gagnant retiré pour {year}.",
"admin.winners.reloaded": "Gagnants rechargés.",
"admin.winners.deletedFor": "Gagnant supprimé pour {year}.",
"admin.winners.addedFor": "Gagnant ajouté pour {year}.",
"admin.backup.deleted": "Sauvegarde supprimée.",
"admin.backup.restoreComplete": "Restore terminé.",
"admin.movies.deletedSuccessfully": "Film(s) supprimé(s) avec succès.",
"admin.movies.updatedFromOmdb": "{count} film(s) mis à jour depuis OMDb.",
"admin.movies.omdbResults": "{success} succès, {errors} échec(s). {details}",
"admin.movies.subtitleDeleted": "Sous-titre supprimé.",
"admin.modal100.saved": "Modal 100% enregistré.",
"admin.content.reloaded": "Contenu rechargé.",
"admin.oscars.invalidYear": "Année invalide. Exemple attendu: 2026",
"admin.oscars.dateSaved": "Date Oscars enregistrée pour {year}: {date}",
"admin.oscars.dateCleared": "Date Oscars effacée pour {year} (retour au défaut: 15 mars).",
"admin.player.settingsSaved": "Réglages du lecteur enregistrés.",
"admin.player.settingsReloaded": "Réglages du lecteur rechargés.",
"admin.activeYear.applied": "Année active appliquée: {year}",
"admin.configuration.reloaded": "Configuration rechargée.",
"admin.categories.categoryNotFound": "Catégorie non trouvée.",
"admin.categories.categoryDeleted": "Catégorie supprimée.",
"admin.categories.categoryUpdated": "Catégorie mise à jour avec succès.",
"admin.categories.deletedCount": "{count} catégorie(s) supprimée(s){errors}",
"admin.oscars.invalidYearShort": "Année invalide.",
"admin.movies.subtitleDeleted": "Sous-titre supprimé.",
"admin.categories.categoryInvalid": "Catégorie invalide.",
"footer.oscarPool": "Oscar Pool",
"footer.by": "par",
"footer.versionNotConfigured": "Maintenant en Full HD",
"footer.versionUnavailable": "Version indisponible (DB)."
}
+97 -65
View File
@@ -3,9 +3,30 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars 2025 - Films</title> <title data-i18n="movies.title">Oscar Pool - Movies</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet"> <link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style> <style>
.navbar-custom { .navbar-custom {
background-color: black; background-color: black;
@@ -19,42 +40,9 @@
color: #ddd; color: #ddd;
} }
/* Style for the 'WATCHED' banner */
.watched-banner {
background-color: green;
color: white;
text-align: center;
padding: 5px;
font-weight: bold;
position: absolute;
top: 10px;
width: 100%;
z-index: 1;
}
/* Centering checkbox and making card look better */
.movie-card {
position: relative;
margin-bottom: 20px;
}
.card-body {
position: relative;
}
#movies-list { #movies-list {
margin-top: 10px; margin-top: 10px;
} }
/* Style for the filter button */
#filter-button {
margin-left: 15px;
}
/* Style for the ratio display */
.ratio {
font-weight: bold;
margin-top: 10px;
}
.navbar-toggler { .navbar-toggler {
border-color: white; /* Make the border white */ border-color: white; /* Make the border white */
} }
@@ -64,63 +52,107 @@
} }
</style> </style>
</head> </head>
<body class="bg-light"> <body class="bg-light page-loading">
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden" data-i18n="common.loading">Loading…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
</div>
</div>
<!-- Black banner with buttons and user info --> <!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom"> <nav class="navbar navbar-expand-lg navbar-custom">
<div class="container"> <div class="container">
<a class="navbar-brand" style="margin-left: -7px;"> <a class="navbar-brand" href="movies.html">
<img src="img/logo_w.png" style="max-width: 220px; height: 70px; object-fit: contain;"> <img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a> </a>
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav"> <div class="navbar-nav">
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a> <a href="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a> <a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a> <a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none" data-i18n="nav.admin">Admin</a>
</div> </div>
<!-- Move user info inside collapsible navbar and align it properly --> <!-- Move user info inside collapsible navbar and align it properly -->
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center"> <div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text me-3"></span> <span id="user-name" class="navbar-text" data-user-name></span>
<button id="log-off" class="btn btn-danger">Déconnexion</button> <button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="M4.93 4.93l1.41 1.41"></path>
<path d="M17.66 17.66l1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="M4.93 19.07l1.41-1.41"></path>
<path d="M17.66 6.34l1.41-1.41"></path>
</svg>
</span>
<span class="theme-toggle-thumb" aria-hidden="true"></span>
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
<div class="container mt-5"> <div class="container mt-4 pb-5">
<div class="d-flex justify-content-between align-items-center"> <!-- Summary card (match Statistiques/Checklist structure) -->
<h2>Films</h2> <div class="card shadow-sm">
<div class="card-body">
<div class="d-flex flex-column gap-1">
<div class="text-muted small">
<span data-i18n="movies.lastUpdated">Last movie update:</span> <span class="fw-semibold text-dark" id="movies-last-updated">—</span>
</div>
<div id="watched-ratio" class="fw-semibold">—</div>
<div class="text-muted small" id="time-until-oscars-container">
<span data-i18n="movies.timeUntilOscarsLabel">Time until the Oscars</span>
&nbsp;(<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
</div>
</div> </div>
<!-- Displaying last updated vod_links --> <div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2 mt-3">
<div class="alert alert-warning fade show" role="alert"> <div class="form-check form-switch m-0">
Dernière mise à jour des films: <strong>09-02-2025</strong>
</div>
<!-- Displaying the ratio of watched movies -->
<div id="watched-ratio" class="ratio">
<!-- This will display the ratio -->
</div>
<h3 class="fs-6">Temps avant les Oscars (2 mars 2025) - <span id="time-left"></span></h3>
<h3 class="fs-6 mb-0">
</h3>
<br>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault"> <input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault">
<label class="form-check-label" for="flexSwitchCheckDefault">Films non regardés seulement</label> <label class="form-check-label" for="flexSwitchCheckDefault" data-i18n="movies.unwatchedOnly">Unwatched films only</label>
</div>
<div class="text-muted small" data-i18n="movies.clickToPlay">Click the poster or title to open the player.</div>
</div>
</div>
</div>
<div id="movies-list" class="row g-3 mt-1">
<div class="col-12">
<div class="text-center py-4 text-muted" aria-live="polite" aria-busy="true" data-i18n="common.loading">Loading…</div>
</div> </div>
<div id="movies-list" class="row">
<!-- Movies will be displayed here -->
</div> </div>
</div> </div>
<!-- Footer Section -->
<footer>
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="./js/app_movies.js"></script> <script src="./js/app_movies.js"></script>
<script src="./js/app_version.js"></script>
</body> </body>
</html> </html>
+294
View File
@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="picks.title">Oscar Pool - My picks</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
background-color: black;
color: white;
}
.navbar-custom .navbar-brand,
.navbar-custom .nav-link {
color: white;
}
.navbar-custom .nav-link:hover {
color: #ddd;
}
.navbar-toggler {
border-color: white;
}
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
}
.nominee-option {
cursor: pointer;
transition: all 0.2s;
position: relative;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.nominee-option:hover {
transform: translateY(-2px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.nominee-option.selected {
font-weight: 600;
box-shadow: 0 2px 6px rgba(13, 110, 253, 0.3);
}
.nominee-checkmark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
font-size: 0.75rem;
font-weight: bold;
}
.nominee-option.selected .nominee-checkmark {
background-color: rgba(255, 255, 255, 0.9);
color: #0d6efd;
}
.picks-table {
border-collapse: separate;
border-spacing: 0;
}
.picks-table tbody tr {
transition: background-color 0.2s;
}
.picks-table tbody tr:hover {
background-color: rgba(13, 110, 253, 0.05);
}
.picks-table tbody tr.has-selection {
background-color: rgba(13, 110, 253, 0.08);
border-left: 3px solid #0d6efd;
}
.selected-choice-badge {
font-weight: 600;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.table-sm td {
padding: 0.5rem;
vertical-align: middle;
}
.table-sm th {
padding: 0.5rem;
font-size: 0.875rem;
}
.user-picks-card {
margin-bottom: 1.5rem;
}
.pick-item {
padding: 0.5rem;
border-bottom: 1px solid #dee2e6;
}
.pick-item:last-child {
border-bottom: none;
}
.correct-pick {
background-color: #d1e7dd;
}
.incorrect-pick {
background-color: #f8d7da;
}
</style>
</head>
<body class="bg-light page-loading">
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden" data-i18n="common.loading">Loading…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
</div>
</div>
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" href="movies.html">
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a>
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<a href="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none" data-i18n="nav.admin">Admin</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text" data-user-name></span>
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="M4.93 4.93l1.41 1.41"></path>
<path d="M17.66 17.66l1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="M4.93 19.07l1.41-1.41"></path>
<path d="M17.66 6.34l1.41-1.41"></path>
</svg>
</span>
<span class="theme-toggle-thumb" aria-hidden="true"></span>
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div>
</div>
</div>
</nav>
<div class="container mt-4 pb-5">
<div id="alert-container"></div>
<!-- Tabs -->
<ul class="nav nav-tabs" id="picksTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-my-picks" data-bs-toggle="tab" data-bs-target="#pane-my-picks" type="button" role="tab" aria-controls="pane-my-picks" aria-selected="true" data-i18n="picks.myPicks">
My picks
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-scores" data-bs-toggle="tab" data-bs-target="#pane-scores" type="button" role="tab" aria-controls="pane-scores" aria-selected="false" data-i18n="picks.scores">
Scores
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link d-none" id="tab-winners" data-bs-toggle="tab" data-bs-target="#pane-winners" type="button" role="tab" aria-controls="pane-winners" aria-selected="false">
<span data-i18n="picks.winners">Winners</span>
<span class="badge bg-warning text-dark ms-1" data-i18n="picks.adminBadge">Admin</span>
</button>
</li>
</ul>
<div class="tab-content pt-3" id="picksTabsContent">
<!-- My Picks Tab -->
<div class="tab-pane fade show active" id="pane-my-picks" role="tabpanel" aria-labelledby="tab-my-picks" tabindex="0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<span class="fw-semibold"><span data-i18n="picks.year">Year:</span> <span id="current-year">—</span></span>
<span class="text-muted small ms-2"><span data-i18n="picks.progress">Progress:</span> <span id="picks-progress">0 / 0</span></span>
</div>
<div class="d-flex gap-2">
<button type="button" id="load-my-picks" class="btn btn-sm btn-outline-secondary" data-i18n="picks.reload">Reload</button>
</div>
</div>
<div id="categories-container">
<div class="text-center py-3 text-muted small" data-i18n="picks.loadingCategories">Loading categories…</div>
</div>
</div>
<!-- Scores Tab -->
<div class="tab-pane fade" id="pane-scores" role="tabpanel" aria-labelledby="tab-scores" tabindex="0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<span class="fw-semibold"><span data-i18n="picks.year">Year:</span> <span id="scores-year">—</span></span>
</div>
<div class="d-flex gap-2">
<button type="button" id="refresh-scores" class="btn btn-sm btn-outline-secondary" data-i18n="common.refresh">Refresh</button>
</div>
</div>
<div id="scores-container">
<div class="text-center py-3 text-muted small" data-i18n="picks.scoresCalculatedAfterWinners">Scores will be calculated after marking winners.</div>
</div>
</div>
<!-- Gagnants Tab (Admin only) -->
<div class="tab-pane fade" id="pane-winners" role="tabpanel" aria-labelledby="tab-winners" tabindex="0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
<span class="fw-semibold"><span data-i18n="picks.year">Year:</span> <span id="winners-year">—</span></span>
<span class="text-muted small ms-2"><span id="winners-progress">0 / 0</span> <span data-i18n="picks.winnersMarked">winners marked</span></span>
</div>
<div class="d-flex gap-2">
<button type="button" id="refresh-winners" class="btn btn-sm btn-outline-secondary" data-i18n="common.refresh">Refresh</button>
</div>
</div>
<div id="winners-container">
<div class="text-center py-3 text-muted small" data-i18n="picks.loadingCategories">Loading categories…</div>
</div>
</div>
</div>
</div>
<!-- Modal for user picks comparison -->
<div class="modal fade" id="userPicksModal" tabindex="-1" aria-labelledby="userPicksModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="userPicksModalLabel" data-i18n="picks.choiceDetails">Choice details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="user-picks-modal-content">
<div class="text-center py-4 text-muted" data-i18n="common.loading">Loading...</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.close">Close</button>
</div>
</div>
</div>
</div>
<!-- Footer Section -->
<footer>
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.js"></script>
<script src="./js/app_picks.js"></script>
<script src="./js/app_version.js"></script>
</body>
</html>
+352
View File
@@ -0,0 +1,352 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- IMPORTANT: the player may use ?token=... for cross-origin video streaming -->
<meta name="referrer" content="no-referrer" />
<title data-i18n="player.title">Oscar Pool - Player</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
background-color: black;
color: white;
}
.navbar-custom .navbar-brand,
.navbar-custom .nav-link {
color: white;
}
.navbar-custom .nav-link:hover {
color: #ddd;
}
.navbar-toggler {
border-color: white;
}
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
}
.player-wrap {
max-width: 1100px;
margin: 0 auto;
}
.player-surface {
background: #000;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(0,0,0,0.1);
}
video {
width: 100%;
height: auto;
display: block;
background: #000;
}
iframe {
width: 100%;
aspect-ratio: 16 / 9;
border: 0;
display: block;
background: #000;
}
.meta-muted {
color: rgba(0,0,0,0.65);
}
.badge-src {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.quality-toggle {
display: inline-flex;
align-items: center;
gap: .6rem;
background: #f8f9fa;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999px;
padding: .25rem .4rem .25rem .6rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.quality-label {
font-size: .8rem;
color: rgba(0,0,0,0.6);
font-weight: 600;
}
.quality-pill {
display: inline-flex;
background: #fff;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.08);
padding: 2px;
gap: 2px;
}
.quality-btn {
border: 0;
background: transparent;
color: rgba(0,0,0,0.65);
font-size: .78rem;
font-weight: 600;
border-radius: 999px;
padding: .25rem .6rem;
line-height: 1.1;
transition: background .2s ease, color .2s ease;
}
.quality-btn.is-active {
background: #111;
color: #fff;
}
.quality-btn:focus-visible {
outline: 2px solid rgba(13,110,253,.35);
outline-offset: 2px;
}
.user-rating {
display: flex;
flex-direction: column;
gap: .25rem;
align-items: center;
text-align: center;
width: 100%;
}
.rating-stars {
display: inline-flex;
align-items: center;
gap: .15rem;
justify-content: center;
}
.star-btn {
border: 0;
background: transparent;
color: rgba(0,0,0,0.5);
font-size: 1.1rem;
line-height: 1;
padding: .15rem .2rem;
}
.star-btn.is-active {
color: #f4b400;
}
html[data-theme="dark"] .star-btn {
color: rgba(255, 255, 255, 0.55);
}
html[data-theme="dark"] .star-btn.is-active {
color: #f4b400;
}
.star-btn:focus-visible {
outline: 2px solid rgba(13,110,253,.35);
outline-offset: 2px;
border-radius: 4px;
}
#user-rating-meta {
color: rgba(0,0,0,0.6);
font-weight: 500;
}
/* Progress indicator: icon-first, minimal text */
.progress-indicator {
display: inline-flex;
align-items: center;
gap: .4rem;
min-height: 1rem;
}
.progress-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
color: rgba(0,0,0,.65);
flex: 0 0 1rem;
}
.progress-icon--spinner {
border: 2px solid rgba(0,0,0,.22);
border-top-color: rgba(0,0,0,.65);
border-radius: 50%;
animation: progressSpin .8s linear infinite;
}
.progress-icon svg {
width: 1rem;
height: 1rem;
display: block;
}
.progress-text {
opacity: .8;
white-space: nowrap;
}
.progress-text.is-hidden {
display: none;
}
@keyframes progressSpin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body class="bg-light page-loading">
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden" data-i18n="common.loading">Loading…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
</div>
</div>
<!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" href="movies.html">
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a>
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<a href="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none" data-i18n="nav.admin">Admin</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text" data-user-name></span>
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="M4.93 4.93l1.41 1.41"></path>
<path d="M17.66 17.66l1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="M4.93 19.07l1.41-1.41"></path>
<path d="M17.66 6.34l1.41-1.41"></path>
</svg>
</span>
<span class="theme-toggle-thumb" aria-hidden="true"></span>
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div>
</div>
</div>
</nav>
<div class="container mt-4 pb-4 player-wrap">
<div id="alert" class="alert alert-danger d-none" role="alert"></div>
<!-- Header / metadata -->
<div class="card shadow-sm mt-3">
<div class="card-body">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div>
<h2 id="movie-title" class="mb-1" data-i18n="common.loading">Loading…</h2>
<div class="text-muted small">
<span id="movie-category"></span><span id="movie-rating"></span>
</div>
</div>
<div class="d-flex flex-column gap-2 align-items-center align-items-lg-center">
<div id="quality-toggle" class="d-none quality-toggle">
<span class="quality-label" data-i18n="player.quality">Quality</span>
<div class="quality-pill" role="group" aria-label="Quality">
<button type="button" class="quality-btn" data-quality="high" id="quality-btn-high" aria-pressed="true" data-i18n="player.high">High</button>
<button type="button" class="quality-btn" data-quality="low" id="quality-btn-low" aria-pressed="false" data-i18n="player.low">Low</button>
</div>
</div>
<div id="user-rating" class="user-rating">
<div class="rating-stars" role="group" data-i18n-aria-label="player.rateThisFilm" aria-label="Rate this film">
<button type="button" class="star-btn" data-value="1" aria-label="1 star" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="2" aria-label="2 stars" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="3" aria-label="3 stars" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="4" aria-label="4 stars" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="5" aria-label="5 stars" aria-pressed="false">★</button>
</div>
<div id="user-rating-meta" class="text-muted small">—</div>
</div>
<!-- Admin-only debug/status (Source + Progress) -->
<div id="player-admin-status" class="d-none d-flex flex-column gap-2">
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary" data-i18n="player.source">Source</span>
<span id="source-label" class="badge bg-light text-dark badge-src">—</span>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary" data-i18n="player.progress">Progress</span>
<span id="progress-status" class="text-muted small progress-indicator">
<span id="progress-status-icon" class="progress-icon" aria-hidden="true"></span>
<span id="progress-status-text" class="progress-text">—</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Player surface -->
<div class="card shadow-sm mt-3">
<div class="card-body p-0">
<div class="player-surface">
<video id="video" class="d-none" controls playsinline></video>
<iframe
id="embed"
class="d-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-presentation"
></iframe>
</div>
</div>
</div>
</div>
<!-- Footer Section -->
<footer class="footer--player">
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<!-- HLS support (m3u8) -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js"></script>
<script src="./js/app_player.js"></script>
<script src="./js/app_version.js"></script>
</body>
</html>
+66 -15
View File
@@ -3,46 +3,97 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars 2025 - Enregistrement</title> <title data-i18n="auth.register">Oscar Pool - Register</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet"> <link href="./css/app.css" rel="stylesheet">
</head> <style>
<style> /* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
#dog { #dog {
width: 30%; width: 30%;
margin: 5px; margin: 5px;
padding: 5px; padding: 5px;
border-radius: 10%; border-radius: 10%;
} }
</style> </style>
<script src="./js/localization.js" defer></script>
</head>
<body class="bg-light"> <body class="bg-light">
<button class="theme-toggle theme-toggle--floating" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="M4.93 4.93l1.41 1.41"></path>
<path d="M17.66 17.66l1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="M4.93 19.07l1.41-1.41"></path>
<path d="M17.66 6.34l1.41-1.41"></path>
</svg>
</span>
<span class="theme-toggle-thumb" aria-hidden="true"></span>
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
</svg>
</span>
</button>
<div class="container d-flex justify-content-center align-items-center vh-100"> <div class="container d-flex justify-content-center align-items-center vh-100">
<div class="card p-4" style="max-width: 400px;"> <!-- Setup needed banner (hidden by default) -->
<div id="setup-needed-card" class="card p-4 d-none" style="max-width: 400px;">
<div class="text-center mb-4"> <div class="text-center mb-4">
<img src="img/logo.png" style="width: 80%";> <img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Oscar Pool">
</div>
<div class="alert alert-warning mb-3">
<strong data-i18n="auth.setupRequired">Setup required</strong><br>
<span data-i18n="auth.appMustBeConfigured">The application must first be configured by an administrator.</span>
</div>
<a href="/" class="btn btn-primary w-100" data-i18n="auth.goToSetup">Go to setup page</a>
</div>
<!-- Normal registration card -->
<div id="register-card" class="card p-4" style="max-width: 400px;">
<div class="text-center mb-4">
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Oscar Pool">
</div> </div>
<form id="register-form"> <form id="register-form">
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Nom</label> <label for="name" class="form-label" data-i18n="auth.name">Name</label>
<input type="text" class="form-control" id="name" required> <input type="text" class="form-control" id="name" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="email" class="form-label">Courriel</label> <label for="email" class="form-label" data-i18n="auth.email">Email</label>
<input type="email" class="form-control" id="email" required> <input type="email" class="form-control" id="email" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="password" class="form-label">Mot de passe</label> <label for="password" class="form-label" data-i18n="auth.password">Password</label>
<input type="password" class="form-control" id="password" required> <input type="password" class="form-control" id="password" required>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="verifoof" class="form-label">Le nom du chien à Cath & Nath?</label> <label for="verifoof" class="form-label" data-i18n="auth.dogQuestion">What is Cath & Nath's dog's name?</label>
<img id="dog" src="img/doglmao.png"> <img id="dog" src="img/doglmao.png">
<input type="text" class="form-control" id="verifoof" required> <input type="text" class="form-control" id="verifoof" required>
</div> </div>
<button type="submit" class="btn btn-primary w-100">Enregistrer</button> <button type="submit" class="btn btn-primary w-100" data-i18n="auth.register">Register</button>
</form> </form>
<p class="mt-3 text-center">Déjà un compte? <a href="/">Connectez-vous</a></p> <p class="mt-3 text-center"><span data-i18n="auth.hasAccount">Already have an account?</span> <a href="/" data-i18n="auth.signIn">Sign in</a></p>
</div> </div>
</div> </div>
@@ -51,15 +102,15 @@
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="responseModalLabel">Statut de l’enregistrement</h5> <h5 class="modal-title" id="responseModalLabel" data-i18n="auth.registrationStatus">Registration status</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body" id="response-message"> <div class="modal-body" id="response-message">
<!-- Response message will be inserted here --> <!-- Response message will be inserted here -->
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Fermer</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" data-i18n="common.close">Close</button>
<button type="button" class="btn btn-primary" id="redirect-login">Retourner à la page d'accueil</button> <button type="button" class="btn btn-primary" id="redirect-login" data-i18n="auth.returnToHome">Return to home page</button>
</div> </div>
</div> </div>
</div> </div>
+214 -29
View File
@@ -3,8 +3,30 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars 2025 - Statistiques des utilisateurs</title> <title data-i18n="stats.title">Oscar Pool - Statistics</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style> <style>
.navbar-custom { .navbar-custom {
background-color: black; background-color: black;
@@ -20,86 +42,249 @@
.user-table { .user-table {
margin-top: 20px; margin-top: 20px;
} }
.ratio {
font-weight: bold;
margin-top: 10px;
}
.navbar-toggler { .navbar-toggler {
border-color: white; /* Make the border white */ border-color: white; /* Make the border white */
} }
.navbar-toggler-icon { .navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
} }
/* Stats tabs */
.stats-tabs .nav-link {
font-weight: 400;
}
.stats-tabs {
border-bottom: 0;
gap: 0.35rem;
}
.stats-tab-panel {
padding-top: 0;
background-color: var(--app-surface);
padding: 0.75rem;
margin-top: -1px;
border: 1px solid var(--app-border);
border-top: 0;
border-radius: 0 0 12px 12px;
}
.stats-tab-panel {
border-top: 1px solid var(--app-border);
}
#statsTabs .nav-link.active {
position: relative;
z-index: 2;
top: 1px;
border-bottom-color: transparent;
}
#statsTabsContent > .tab-pane.active {
position: relative;
}
#statsTabsContent > .tab-pane.active::before {
content: "";
position: absolute;
top: 0;
left: 0;
height: 2px;
width: calc(100% + 2px);
margin-left: -1px;
background: var(--app-surface);
pointer-events: none;
}
#statsTabsContent > .tab-pane.fade::before {
background: transparent;
}
.stats-tab-panel > .tab-pane:not(#pane-ranking) {
padding-top: 1rem;
}
#pane-ranking .user-table {
margin-top: 0.25rem;
}
.stats-tab-panel .table,
.stats-tab-panel .table th,
.stats-tab-panel .table td {
font-size: 0.9rem;
}
html[data-theme="dark"] .stats-tabs .nav-link.active {
border-bottom-color: transparent;
}
</style> </style>
</head> </head>
<body class="bg-light"> <body class="bg-light page-loading">
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden" data-i18n="common.loading">Loading…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
</div>
</div>
<!-- Black banner with buttons and user info --> <!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom"> <nav class="navbar navbar-expand-lg navbar-custom">
<div class="container"> <div class="container">
<a class="navbar-brand" style="margin-left: -7px;"> <a class="navbar-brand" href="movies.html">
<img src="img/logo_w.png" style="max-width: 220px; height: 70px; object-fit: contain;"> <img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a> </a>
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav"> <div class="navbar-nav">
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a> <a href="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Movies</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a> <a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a> <a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none" data-i18n="nav.admin">Admin</a>
</div> </div>
<!-- Move user info inside collapsible navbar and align it properly --> <!-- Move user info inside collapsible navbar and align it properly -->
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center"> <div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text me-3"></span> <span id="user-name" class="navbar-text" data-user-name></span>
<button id="log-off" class="btn btn-danger">Déconnexion</button> <button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="M4.93 4.93l1.41 1.41"></path>
<path d="M17.66 17.66l1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="M4.93 19.07l1.41-1.41"></path>
<path d="M17.66 6.34l1.41-1.41"></path>
</svg>
</span>
<span class="theme-toggle-thumb" aria-hidden="true"></span>
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
<div class="container mt-5"> <div class="container mt-4">
<h2>Statistiques des utilisateurs</h2> <!-- Tabs -->
<ul class="nav nav-tabs stats-tabs" id="statsTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-ranking" data-bs-toggle="tab" data-bs-target="#pane-ranking" type="button" role="tab" aria-controls="pane-ranking" aria-selected="true">
<span data-i18n="stats.ranking">Ranking</span>
<span id="stats-active-year-badge" class="badge bg-dark ms-2 d-none" aria-label="Active year"></span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-winners" data-bs-toggle="tab" data-bs-target="#pane-winners" type="button" role="tab" aria-controls="pane-winners" aria-selected="false" data-i18n="stats.winners">
Winners 🏆
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-completion" data-bs-toggle="tab" data-bs-target="#pane-completion" type="button" role="tab" aria-controls="pane-completion" aria-selected="false" data-i18n="stats.completers">
100%
</button>
</li>
</ul>
<!-- Spinner shown while loading --> <div class="tab-content stats-tab-panel" id="statsTabsContent">
<div id="loading-spinner" class="spinner-border text-primary" role="status"> <!-- Ranking tab -->
<span class="visually-hidden">Loading...</span> <div class="tab-pane fade show active" id="pane-ranking" role="tabpanel" aria-labelledby="tab-ranking" tabindex="0">
</div> <div id="stats-error" class="alert alert-danger d-none" role="alert"></div>
<!-- Table for users and movies watched --> <!-- Table for users and movies watched -->
<table class="table user-table" style="display: none;"> <table class="table table-striped table-hover align-middle user-table d-none">
<thead> <thead>
<tr> <tr>
<th scope="col">Nom</th> <th scope="col" data-i18n="stats.rank">#</th>
<th scope="col">Total des films regardés (pts)</th> <th scope="col" data-i18n="stats.name">Name</th>
<th scope="col">Ratio</th> <th scope="col" id="films-header" data-i18n="stats.films">Movies</th>
<th scope="col" id="bon-picks-header" data-i18n="stats.correctPicks">Correct picks</th>
<th scope="col" class="text-center" data-i18n="stats.points">Points</th>
</tr> </tr>
</thead> </thead>
<tbody id="user-table-body"> <tbody id="user-table-body">
<!-- User rows will be inserted here --> <!-- User rows will be inserted here -->
</tbody> </tbody>
</table> </table>
</div>
<!-- Winners tab -->
<div class="tab-pane fade" id="pane-winners" role="tabpanel" aria-labelledby="tab-winners" tabindex="0">
<div class="table-responsive mb-4">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col" style="width: 110px;" data-i18n="stats.year">Year</th>
<th scope="col" data-i18n="stats.winnersColumn">Winner(s)</th>
</tr>
</thead>
<tbody id="winners-table-body">
<tr>
<td colspan="2" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true" data-i18n="common.loading">Loading…</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 100% completers tab -->
<div class="tab-pane fade" id="pane-completion" role="tabpanel" aria-labelledby="tab-completion" tabindex="0">
<div class="small text-muted mb-2 d-flex align-items-center gap-2">
<span class="badge bg-light text-dark border" aria-hidden="true">i</span>
<span data-i18n="stats.completersHelp">Users who have watched 100% (by year)</span>
</div>
<div class="table-responsive mb-4">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col" style="width: 110px;" data-i18n="stats.year">Year</th>
<th scope="col" style="width: 140px;" data-i18n="stats.totalFilms">Total films</th>
<th scope="col" data-i18n="stats.names">Names</th>
</tr>
</thead>
<tbody id="completers-table-body">
<tr>
<td colspan="3" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true" data-i18n="common.loading">Loading…</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
<!-- Modal for displaying movies watched --> <!-- Modal for displaying movies watched -->
<div class="modal fade" id="moviesModal" tabindex="-1" aria-labelledby="moviesModalLabel" aria-hidden="true"> <div class="modal fade" id="moviesModal" tabindex="-1" aria-labelledby="moviesModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="moviesModalLabel">Films regardés</h5> <h5 class="modal-title" id="moviesModalLabel" data-i18n="stats.moviesWatched">Movies watched</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<ul id="movie-list" class="list-group"> <div class="d-flex flex-column gap-3">
<!-- Movie titles will be inserted here --> <div id="movies-summary" class="text-muted small"></div>
</ul>
<div id="movies-empty" class="text-muted d-none" data-i18n="stats.noMoviesToShow">No movies to show.</div>
<div id="movie-grid" class="stats-movies-grid" aria-live="polite"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Footer Section -->
<footer>
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="./js/app_user-stat.js"></script> <script src="./js/app_user-stat.js"></script>
<script src="./js/app_version.js"></script>
</body> </body>
</html> </html>
Binary file not shown.
+155
View File
@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="viewPicks.title">Oscar Pool - View All Picks</title>
<script src="./js/app_theme.js"></script>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<style>
/* Hide translatable content until translations are ready - prevents flash during page navigation */
body:not(.i18n-ready) [data-i18n] {
opacity: 0;
transition: opacity 0.1s ease-in;
}
body.i18n-ready [data-i18n] {
opacity: 1;
}
/* Fallback: show content after 2 seconds even if JS fails */
body:not(.i18n-ready) [data-i18n] {
animation: show-after-delay 0.1s 2s forwards;
}
@keyframes show-after-delay {
to { opacity: 1; }
}
</style>
<script src="./js/localization.js" defer></script>
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
background-color: black;
color: white;
}
.navbar-custom .navbar-brand,
.navbar-custom .nav-link {
color: white;
}
.navbar-custom .nav-link:hover {
color: #ddd;
}
.navbar-toggler {
border-color: white;
}
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
}
.user-picks-card {
margin-bottom: 1.5rem;
}
.pick-item {
padding: 0.5rem;
border-bottom: 1px solid #dee2e6;
}
.pick-item:last-child {
border-bottom: none;
}
.correct-pick {
background-color: #d1e7dd;
}
.incorrect-pick {
background-color: #f8d7da;
}
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" href="movies.html">
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a>
<span class="navbar-text ms-auto me-2 d-lg-none navbar-user-name-mobile" data-user-name></span>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<a href="movies.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.films">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.checklist">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2" data-i18n="nav.stats">Stats</a>
<a href="picks.html" class="nav-link btn btn-outline-light me-2" id="nav-link-picks" style="display: none;" data-i18n="nav.myPicks">My picks</a>
<a href="control.html" class="nav-link btn btn-warning me-2" data-i18n="nav.admin">Admin</a>
<a href="view-picks.html" class="nav-link btn btn-info me-2 active" data-i18n="nav.viewAllPicks">View All Picks</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2 navbar-user">
<span id="user-name" class="navbar-text" data-user-name></span>
<button class="theme-toggle" type="button" data-theme-toggle role="switch" aria-checked="false" data-i18n-aria-label="common.toggleDarkMode" aria-label="Toggle dark mode">
<span class="theme-toggle-icon theme-toggle-icon-sun" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2"></path>
<path d="M12 20v2"></path>
<path d="M4.93 4.93l1.41 1.41"></path>
<path d="M17.66 17.66l1.41 1.41"></path>
<path d="M2 12h2"></path>
<path d="M20 12h2"></path>
<path d="M4.93 19.07l1.41-1.41"></path>
<path d="M17.66 6.34l1.41-1.41"></path>
</svg>
</span>
<span class="theme-toggle-thumb" aria-hidden="true"></span>
<span class="theme-toggle-icon theme-toggle-icon-moon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"></path>
</svg>
</span>
</button>
<button id="log-off" class="btn btn-danger" data-i18n="nav.logout">Log out</button>
</div>
</div>
</div>
</nav>
<div class="container mt-4 pb-5">
<h2 data-i18n="viewPicks.title">View All Picks</h2>
<div id="alert-container"></div>
<div class="card shadow-sm mt-3 mb-4">
<div class="card-body">
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2">
<div>
<div class="fw-semibold"><span data-i18n="picks.year">Year:</span> <span id="current-year">—</span></div>
<div class="text-muted small" data-i18n="viewPicks.overview">Overview of all user picks</div>
</div>
<div class="d-flex gap-2">
<button type="button" id="refresh-picks" class="btn btn-outline-secondary" data-i18n="common.refresh">Refresh</button>
</div>
</div>
<div class="mt-3">
<div class="text-muted small">
<span id="users-count">0</span> <span data-i18n="viewPicks.usersSubmitted" data-i18n-params='{"count": ""}'>user(s) have submitted their picks</span>
</div>
</div>
</div>
</div>
<div id="picks-container">
<div class="text-center py-4 text-muted" data-i18n="viewPicks.loadingPicks">Loading picks…</div>
</div>
</div>
<!-- Footer Section -->
<footer>
<p id="footer-copy"><span data-i18n="footer.oscarPool">Oscar Pool</span> <span aria-hidden="true">·</span> <a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.js"></script>
<script src="./js/app_view-picks.js"></script>
<script src="./js/app_version.js"></script>
</body>
</html>
+206
View File
@@ -0,0 +1,206 @@
# Python video server (Range streaming + JWT + Mongo)
This is a small standalone server that mirrors the app’s `/api/video/:id` behavior:
- Looks up `movies._id == :id` in MongoDB and reads the `video_file` field
- Streams the file from `VIDEO_FILES_DIR` with **HTTP Range** support (seeking works)
- Auth via the same JWT (`Authorization: Bearer ...`), plus a `video_auth` cookie for `<video>`
## Run
```bash
python -m pip install -r python_video_server/requirements.txt
export MONGO_URI="..."
export MONGO_DB_NAME="..." # optional; required if MONGO_URI doesn't include /<dbName>
export JWT_SECRET="..."
export VIDEO_FILES_DIR="/absolute/path/to/public/video" # optional (default: ./public/video)
export VIDEO_SESSION_MAX_AGE_SECONDS="28800" # optional (default: 8h)
uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000
or
python3 -m uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000 --ssl-certfile python_video_server/certs/config/live/[site]/fullchain.pem --ssl-keyfile python_video_server/certs/config/live/[site]/privkey.pem
```
## Extra environment variables (optional)
```bash
# CORS (needed when the main app is on a different origin)
export CORS_ALLOW_ORIGINS="https://your-app.example.com" # default: "*"
# Cookie behavior for /api/video/session
export VIDEO_COOKIE_SAMESITE="Lax" # Lax|Strict|None (default: Lax)
# Mongo TLS / connectivity troubleshooting (Atlas, odd hosts, etc.)
export MONGO_TLS_CA_FILE="/path/to/ca.pem" # prefer a valid CA bundle
export MONGO_TLS_FORCE_TLS12="1" # force TLS 1.2 if TLS 1.3 breaks
export MONGO_TLS_INSECURE="1" # NOT recommended; debug only
export MONGO_SERVER_SELECTION_TIMEOUT_MS="8000"
export MONGO_CONNECT_TIMEOUT_MS="8000"
export MONGO_SOCKET_TIMEOUT_MS="20000"
```
## Video auth / usage
`GET /api/video/{movieId}` accepts auth in three ways (in priority order):
1) `video_auth` cookie (best for browser `<video>` tags)
2) `Authorization: Bearer <token>` header
3) `?token=<token>` query param (debug only; tokens in URLs leak via logs/referrers)
### Browser `<video>` (cookie flow)
Because HTML `<video>` cannot send an `Authorization` header, the pattern is:
1. Your JS calls `POST /api/video/session` with `Authorization: Bearer <token>`
2. The server responds `204` and sets a `video_auth` cookie (scoped to `/api/video`)
3. Your `<video>` loads `/api/video/{movieId}` and the cookie is used automatically
Example (curl):
```bash
# 1) exchange header token -> cookie
curl -i -X POST "https://video.example.com/api/video/session" \
-H "Authorization: Bearer $JWT"
# 2) then the <video> element can simply use:
# https://video.example.com/api/video/<movieId>
```
Cross-site note (frontend on a different origin):
- Set `CORS_ALLOW_ORIGINS` to your exact app origin (not `*`) and ensure your frontend uses `credentials: "include"` on the `/api/video/session` request.
- If the video host is cross-site, you’ll usually need `VIDEO_COOKIE_SAMESITE=None` **and** HTTPS, otherwise browsers may block the cookie.
### Non-browser clients (header flow)
```bash
curl -I "https://video.example.com/api/video/<movieId>" \
-H "Authorization: Bearer $JWT"
```
### Token in URL (debug flow)
```bash
curl -I "https://video.example.com/api/video/<movieId>?token=$JWT"
```
### Seeking / Range requests (example)
```bash
curl -i "https://video.example.com/api/video/<movieId>" \
-H "Authorization: Bearer $JWT" \
-H "Range: bytes=0-1023"
```
## HTTPS (optional)
You can run behind a reverse proxy/ingress, or run `uvicorn` with TLS directly:
```bash
uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
--ssl-certfile /path/to/fullchain.pem \
--ssl-keyfile /path/to/privkey.pem
```
Notes:
- If you’re behind a proxy, forward `X-Forwarded-Proto: https` so `/api/video/session` can set `Secure` cookies correctly.
## Let’s Encrypt certs (python-only helper)
This repo includes `python_video_server/letsencrypt.py`, a wrapper around the **certbot Python package** (no system `certbot` needed).
Certificates (and certbot state) are stored under:
- `python_video_server/certs/config/live/<primary-domain>/fullchain.pem`
- `python_video_server/certs/config/live/<primary-domain>/privkey.pem`
You can override the storage directory with `LETSENCRYPT_BASE_DIR` (optional).
### Issue a cert (HTTP-01 standalone, easiest)
Requirements:
- DNS `A/AAAA` records point to this host
- Port **80** reachable from the internet
- You can bind port 80 (often requires `sudo`)
```bash
python -m pip install -r python_video_server/requirements.txt
# staging by default (recommended first)
sudo -E python -m python_video_server.letsencrypt certonly \
--email [email protected] \
--domains example.com,www.example.com
# production (real cert)
sudo -E python -m python_video_server.letsencrypt certonly \
--email [email protected] \
--domains example.com,www.example.com \
--production
```
Then run uvicorn with those certs:
```bash
sudo -E uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
--ssl-certfile python_video_server/certs/config/live/example.com/fullchain.pem \
--ssl-keyfile python_video_server/certs/config/live/example.com/privkey.pem
```
### Issue a cert (DNS-01 manual TXT, no port 80)
Use this if you cannot expose/bind port 80. Certbot will prompt you to create a TXT record.
```bash
python -m pip install -r python_video_server/requirements.txt
python -m python_video_server.letsencrypt certonly \
--challenge dns \
--email [email protected] \
--domains example.com,www.example.com \
--production
```
### Renew certs
```bash
# dry-run (safe)
sudo -E python -m python_video_server.letsencrypt renew --dry-run
# real renewal
sudo -E python -m python_video_server.letsencrypt renew --production
```
## Endpoints
- `POST /api/video/session`
Sets `video_auth` cookie (send `Authorization: Bearer <token>`). Returns `204 No Content`.
- `GET|HEAD /api/video/{movieId}`
Streams the movie file (supports `Range: bytes=...`). Returns `200` (full file) or `206` (partial content) or `404` (not found).
- `GET /healthz`
Health check endpoint. Returns JSON with server status, video files directory, MongoDB database name, and OpenSSL version.
## Functions Reference
### Public Endpoints
- `create_video_session(request, response, authorization)` - POST `/api/video/session` - Creates video authentication cookie
- `stream_video(movie_id, request, authorization, range)` - GET|HEAD `/api/video/{movie_id}` - Streams video file with Range support
- `healthz()` - GET `/healthz` - Health check endpoint
### Helper Functions (Internal)
- `_env_str(name, default)` - Get environment variable as string
- `_jwt_secret()` - Get JWT secret from environment
- `_mongo_uri()` - Get MongoDB connection URI from environment
- `_mongo_db_name()` - Get MongoDB database name (from env or parsed from URI)
- `_env_int(name, default)` - Get environment variable as integer
- `_video_files_dir()` - Get video files directory path (default: `./public/video`)
- `_parse_bearer(authorization)` - Parse Bearer token from Authorization header
- `_is_safe_relative_file(path)` - Security check: validates file path is safe (no directory traversal)
- `_guess_content_type(file_path)` - Guess MIME content type from file extension
- `_parse_range(range_header, file_size)` - Parse HTTP Range header into start/end tuple
- `_file_iter(path, start, end, chunk_size)` - Generator that yields file chunks for streaming
- `_get_token(request, authorization)` - Get authentication token from cookie, header, or query string (priority order)
- `_verify_token(token)` - Verify JWT token and return decoded payload
- `_mongo_client()` - Create MongoDB client with TLS/connection timeout configuration
- `_mongo_client_cached()` - Cached MongoDB client (LRU cache, reused across requests)
- `_shutdown()` - Event handler for graceful MongoDB client shutdown
+423
View File
@@ -0,0 +1,423 @@
"""
Let’s Encrypt certificate issuance/renewal using Python-only certbot.
Why this exists:
- You already run the server with `uvicorn`.
- Uvicorn can serve HTTPS when given cert/key PEM files.
- The missing piece is obtaining and renewing a trusted certificate.
This module wraps the *certbot Python package* (no system `certbot` binary required).
Typical usage (standalone HTTP-01, binds to port 80):
sudo -E python -m python_video_server.letsencrypt certonly \
--email [email protected] \
--domains example.com,www.example.com \
--production
If you *cannot* bind port 80 (shared hosting, no root, port already in use),
use DNS-01 instead (manual TXT record):
python3 -m python_video_server.letsencrypt certonly \
--challenge dns \
--email [email protected] \
--domains example.com,www.example.com \
--production
Then run uvicorn with:
sudo -E uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
--ssl-certfile python_video_server/certs/config/live/example.com/fullchain.pem \
--ssl-keyfile python_video_server/certs/config/live/example.com/privkey.pem
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from typing import Iterable, List
LE_PRODUCTION_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
LE_STAGING_DIRECTORY_URL = "https://acme-staging-v02.api.letsencrypt.org/directory"
def _default_base_dir() -> Path:
# Keep certbot state inside the repo by default (and gitignored).
# This mirrors certbot's default directory structure:
# - config/live/<domain>/{fullchain.pem,privkey.pem,...}
# - config/renewal/<domain>.conf
# - work/*
# - logs/*
return (Path(__file__).resolve().parent / "certs").resolve()
def _split_domains(domains_csv: str) -> List[str]:
domains = [d.strip() for d in (domains_csv or "").split(",") if d.strip()]
# Deduplicate while preserving order
out: List[str] = []
seen = set()
for d in domains:
if d not in seen:
out.append(d)
seen.add(d)
return out
def _ensure_dirs(base_dir: Path) -> tuple[Path, Path, Path]:
config_dir = (base_dir / "config").resolve()
work_dir = (base_dir / "work").resolve()
logs_dir = (base_dir / "logs").resolve()
config_dir.mkdir(parents=True, exist_ok=True)
work_dir.mkdir(parents=True, exist_ok=True)
logs_dir.mkdir(parents=True, exist_ok=True)
return config_dir, work_dir, logs_dir
def _ensure_dns_hook_scripts(work_dir: Path) -> tuple[Path, Path]:
"""
Create small executable hook scripts used by certbot's --manual DNS flow.
Certbot runs these hooks as external executables; we generate them under the
certbot work dir so no special permissions are needed.
"""
auth_hook = (work_dir / "certbot_dns_auth_hook.py").resolve()
cleanup_hook = (work_dir / "certbot_dns_cleanup_hook.py").resolve()
auth_contents = """#!/usr/bin/env python3
import os
import sys
import time
def _open_tty():
# Certbot may capture stdout/stderr of hooks; /dev/tty forces user-visible IO
# when an interactive terminal exists.
try:
return open("/dev/tty", "r+", encoding="utf-8", buffering=1)
except Exception:
return None
def main() -> int:
domain = os.environ.get("CERTBOT_DOMAIN", "").strip()
validation = os.environ.get("CERTBOT_VALIDATION", "").strip()
if not domain or not validation:
print("Missing CERTBOT_DOMAIN/CERTBOT_VALIDATION in environment.", file=sys.stderr, flush=True)
return 2
record_name = f"_acme-challenge.{domain}"
msg = (
"\\n=== DNS-01 challenge required ===\\n"
"Create/Update this TXT record:\\n"
f" Name: {record_name}\\n"
" Type: TXT\\n"
f" Value: {validation}\\n"
"\\nAfter the record is published and has propagated, press Enter to continue.\\n"
"> "
)
tty = _open_tty()
if tty is not None:
tty.write(msg)
tty.flush()
try:
tty.readline()
except KeyboardInterrupt:
tty.write("\\nAborted.\\n")
tty.flush()
return 130
finally:
try:
tty.close()
except Exception:
pass
return 0
# Fallback: best-effort stdout + either wait or read stdin if interactive.
print(msg, end="", flush=True)
if not sys.stdin.isatty():
wait_s = int(os.environ.get("LE_DNS_WAIT_SECONDS", "300"))
print(f"\\n(non-interactive stdin detected; waiting {wait_s}s then continuing...)", flush=True)
time.sleep(max(0, wait_s))
return 0
try:
input("")
except KeyboardInterrupt:
print("\\nAborted.", file=sys.stderr, flush=True)
return 130
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""
cleanup_contents = """#!/usr/bin/env python3
import os
def _open_tty():
try:
return open("/dev/tty", "w", encoding="utf-8", buffering=1)
except Exception:
return None
def main() -> int:
domain = os.environ.get("CERTBOT_DOMAIN", "").strip()
if not domain:
return 0
msg = "\\nDNS-01 cleanup:\\nYou may now remove the TXT record: _acme-challenge.%s\\n" % domain
tty = _open_tty()
if tty is not None:
tty.write(msg)
tty.flush()
try:
tty.close()
except Exception:
pass
return 0
print(msg, end="", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""
auth_hook.write_text(auth_contents, encoding="utf-8")
cleanup_hook.write_text(cleanup_contents, encoding="utf-8")
os.chmod(auth_hook, 0o755)
os.chmod(cleanup_hook, 0o755)
return auth_hook, cleanup_hook
def _run_certbot(argv: Iterable[str]) -> int:
try:
# certbot is a Python package dependency (installed via pip).
# Importing here keeps the server import path clean.
import certbot.main # type: ignore
except Exception as e:
raise SystemExit(
"Missing dependency: certbot is not installed.\n"
"Install it with:\n"
" python -m pip install -r python_video_server/requirements.txt\n"
f"\nOriginal import error: {e!r}"
)
return int(certbot.main.main(list(argv)))
def _common_certbot_flags(
*,
email: str,
domains: List[str],
base_dir: Path,
production: bool,
non_interactive: bool = True,
) -> List[str]:
if not email.strip():
raise SystemExit("--email is required")
if not domains:
raise SystemExit("--domains is required (comma-separated)")
config_dir, work_dir, logs_dir = _ensure_dirs(base_dir)
args: List[str] = [
"--agree-tos",
"--email",
email.strip(),
"--config-dir",
str(config_dir),
"--work-dir",
str(work_dir),
"--logs-dir",
str(logs_dir),
# Keep key type modern; certbot defaults may vary by version.
"--key-type",
"ecdsa",
]
if non_interactive:
args.insert(0, "--non-interactive")
if production:
args += ["--server", LE_PRODUCTION_DIRECTORY_URL]
else:
args += ["--server", LE_STAGING_DIRECTORY_URL]
for d in domains:
args += ["-d", d]
return args
def cmd_certonly(args: argparse.Namespace) -> int:
base_dir = Path(args.base_dir).expanduser().resolve() if args.base_dir else _default_base_dir()
domains = _split_domains(args.domains)
production = bool(args.production)
challenge = (args.challenge or "http").strip().lower()
if challenge not in {"http", "dns"}:
raise SystemExit("--challenge must be one of: http, dns")
if challenge == "dns":
# DNS-01 works without binding any local ports; suitable for shared hosting / no root.
#
# Default: use certbot's built-in interactive manual prompt (most reliable).
# Optional: --dns-hooks to use our hook scripts (useful for automation).
certbot_args = [
"certonly",
"--manual",
"--preferred-challenges",
"dns",
"--manual-public-ip-logging-ok",
]
if bool(args.dns_hooks):
_, work_dir, _ = _ensure_dirs(base_dir)
auth_hook, cleanup_hook = _ensure_dns_hook_scripts(work_dir)
certbot_args += [
"--manual-auth-hook",
str(auth_hook),
"--manual-cleanup-hook",
str(cleanup_hook),
]
certbot_args += _common_certbot_flags(
email=args.email,
domains=domains,
base_dir=base_dir,
production=production,
non_interactive=True,
)
else:
# IMPORTANT: manual DNS prompt requires interactive mode.
certbot_args += _common_certbot_flags(
email=args.email,
domains=domains,
base_dir=base_dir,
production=production,
non_interactive=False,
)
else:
certbot_args = [
"certonly",
"--standalone",
# HTTP-01 is the simplest/most compatible for typical VPS setups.
"--preferred-challenges",
"http",
# If port 80 is already in use, certbot can still succeed behind a reverse proxy
# using `--webroot`, but this wrapper defaults to standalone by design.
"--http-01-port",
str(int(args.http_01_port)),
*_common_certbot_flags(
email=args.email,
domains=domains,
base_dir=base_dir,
production=production,
non_interactive=True,
),
]
if args.force_renewal:
certbot_args.append("--force-renewal")
if args.test_cert:
certbot_args.append("--test-cert")
return _run_certbot(certbot_args)
def cmd_renew(args: argparse.Namespace) -> int:
base_dir = Path(args.base_dir).expanduser().resolve() if args.base_dir else _default_base_dir()
production = bool(args.production)
config_dir, work_dir, logs_dir = _ensure_dirs(base_dir)
certbot_args: List[str] = [
"renew",
"--non-interactive",
"--config-dir",
str(config_dir),
"--work-dir",
str(work_dir),
"--logs-dir",
str(logs_dir),
"--key-type",
"ecdsa",
]
certbot_args += ["--server", LE_PRODUCTION_DIRECTORY_URL if production else LE_STAGING_DIRECTORY_URL]
if args.force_renewal:
certbot_args.append("--force-renewal")
if args.dry_run:
certbot_args.append("--dry-run")
return _run_certbot(certbot_args)
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="python -m python_video_server.letsencrypt",
description="Python-only Let’s Encrypt cert issuance/renewal (wraps certbot package).",
)
p.add_argument(
"--base-dir",
default=os.environ.get("LETSENCRYPT_BASE_DIR", ""),
help="Where certbot state is stored (default: python_video_server/certs).",
)
p.add_argument(
"--production",
action="store_true",
help="Use Let’s Encrypt production endpoint (default uses staging).",
)
sub = p.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("certonly", help="Issue (or re-issue) a certificate via HTTP-01 standalone.")
c.add_argument("--email", required=True, help="Email for Let’s Encrypt account registration.")
c.add_argument(
"--domains",
required=True,
help="Comma-separated domains (e.g. example.com,www.example.com). The first is used as the primary cert name.",
)
c.add_argument(
"--http-01-port",
default=int(os.environ.get("LE_HTTP01_PORT", "80")),
type=int,
help="Port to bind for HTTP-01 challenge (default: 80).",
)
c.add_argument(
"--challenge",
default=os.environ.get("LE_CHALLENGE", "http"),
choices=["http", "dns"],
help="ACME challenge type: http (standalone) or dns (manual TXT record). Default: http.",
)
c.add_argument(
"--dns-hooks",
action="store_true",
default=bool(int(os.environ.get("LE_DNS_HOOKS", "0"))),
help="(DNS challenge only) Use hook scripts to print token and wait. Default uses certbot's interactive manual prompt.",
)
c.add_argument("--force-renewal", action="store_true", help="Force issuance even if not close to expiry.")
c.add_argument(
"--test-cert",
action="store_true",
help="Obtain a test certificate from staging (alias for staging behavior; kept for familiarity).",
)
c.set_defaults(_handler=cmd_certonly)
r = sub.add_parser("renew", help="Renew any existing certificates in the base-dir.")
r.add_argument("--dry-run", action="store_true", help="Perform a dry-run renewal.")
r.add_argument("--force-renewal", action="store_true", help="Force renewal even if not due.")
r.set_defaults(_handler=cmd_renew)
return p
def main(argv: List[str] | None = None) -> int:
parser = _build_parser()
ns = parser.parse_args(argv if argv is not None else sys.argv[1:])
handler = getattr(ns, "_handler", None)
if handler is None:
raise SystemExit("No command selected")
return int(handler(ns))
if __name__ == "__main__":
raise SystemExit(main())
+6
View File
@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
pymongo==4.10.1
PyJWT==2.10.1
certbot>=2.11.0,<3
certifi==2025.11.12
+374
View File
@@ -0,0 +1,374 @@
import mimetypes
import os
import posixpath
import ssl
from functools import lru_cache
from datetime import datetime, timezone
from pathlib import Path
from typing import Generator, Optional, Tuple
from urllib.parse import quote
from urllib.parse import urlparse
import jwt
from bson import ObjectId
from fastapi import FastAPI, Header, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from pymongo import MongoClient
from pymongo.errors import PyMongoError
def _env_str(name: str, default: str = "") -> str:
v = os.environ.get(name)
return v.strip() if isinstance(v, str) and v.strip() else default
def _jwt_secret() -> str:
# Read env at runtime (important on Windows/uvicorn worker reloads).
return _env_str("JWT_SECRET")
def _mongo_uri() -> str:
# Read env at runtime (important on Windows/uvicorn worker reloads).
return _env_str("MONGO_URI")
def _mongo_db_name() -> str:
# Prefer explicit env var, otherwise try to parse from URI path.
explicit = _env_str("MONGO_DB_NAME")
if explicit:
return explicit
uri = _mongo_uri()
if not uri:
return ""
try:
parsed = urlparse(uri)
# Path is like "/dbname"
db = (parsed.path or "").lstrip("/")
# Strip any extra segments just in case
db = db.split("/")[0].strip()
return db
except Exception:
return ""
def _env_int(name: str, default: int) -> int:
try:
return int(_env_str(name, str(default)))
except Exception:
return default
def _video_files_dir() -> Path:
# Read env at runtime (important on Windows/uvicorn worker reloads).
return Path(_env_str("VIDEO_FILES_DIR", str(Path.cwd() / "public" / "video"))).resolve()
def _parse_bearer(authorization: Optional[str]) -> str:
raw = (authorization or "").strip()
if raw.lower().startswith("bearer "):
return raw[7:].strip()
return ""
def _is_safe_relative_file(p: str) -> bool:
if not isinstance(p, str):
return False
s = p.strip()
if not s or "\x00" in s:
return False
if s.startswith("/") or s.startswith("\\"):
return False
norm = posixpath.normpath(s.replace("\\", "/"))
if norm == ".." or norm.startswith("../"):
return False
return True
def _guess_content_type(file_path: Path) -> str:
ctype, _ = mimetypes.guess_type(str(file_path))
return ctype or "application/octet-stream"
def _parse_range(range_header: str, file_size: int) -> Optional[Tuple[int, int]]:
# Only supports a single bytes range: "bytes=start-end"
raw = (range_header or "").strip()
if not raw:
return None
if not raw.lower().startswith("bytes="):
return None
spec = raw[6:].strip()
if "," in spec:
return None
if "-" not in spec:
return None
start_s, end_s = spec.split("-", 1)
start = int(start_s) if start_s else 0
end = int(end_s) if end_s else (file_size - 1)
if start < 0 or end < 0 or start > end or start >= file_size:
return None
end = min(end, file_size - 1)
return start, end
def _file_iter(path: Path, start: int, end: int, chunk_size: int = 1024 * 1024) -> Generator[bytes, None, None]:
with path.open("rb") as f:
f.seek(start)
remaining = end - start + 1
while remaining > 0:
n = min(chunk_size, remaining)
data = f.read(n)
if not data:
break
remaining -= len(data)
yield data
def _get_token(req: Request, authorization: Optional[str]) -> str:
# Prefer cookie (for <video>), then Authorization header, then querystring token.
cookie_token = (req.cookies.get("video_auth") or "").strip()
if cookie_token:
return cookie_token
header_token = _parse_bearer(authorization)
if header_token:
return header_token
qs = (req.query_params.get("token") or "").strip()
return qs
def _verify_token(token: str) -> dict:
if not token:
raise HTTPException(status_code=401, detail="Missing video auth token")
secret = _jwt_secret()
if not secret:
raise HTTPException(status_code=500, detail="JWT_SECRET is not configured")
try:
return jwt.decode(token, secret, algorithms=["HS256"])
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid or expired video auth token")
def _mongo_client() -> MongoClient:
uri = _mongo_uri()
if not uri:
raise HTTPException(status_code=500, detail="MONGO_URI is not configured")
kwargs = {
# Fail fast instead of hanging for ~30s on each request.
"serverSelectionTimeoutMS": _env_int("MONGO_SERVER_SELECTION_TIMEOUT_MS", 8000),
"connectTimeoutMS": _env_int("MONGO_CONNECT_TIMEOUT_MS", 8000),
"socketTimeoutMS": _env_int("MONGO_SOCKET_TIMEOUT_MS", 20000),
}
# On some hosts, the system CA store is missing/broken or overridden by env vars,
# causing TLS handshake failures to MongoDB Atlas. Prefer certifi's CA bundle.
ca_file = _env_str("MONGO_TLS_CA_FILE")
if ca_file:
kwargs["tlsCAFile"] = ca_file
else:
try:
import certifi # type: ignore
kwargs["tlsCAFile"] = certifi.where()
except Exception:
# If certifi isn't available, fall back to system trust.
pass
# Some hosts / middleboxes break TLS 1.3 handshakes to Atlas and produce
# `TLSV1_ALERT_INTERNAL_ERROR`. Forcing TLS 1.2 can fix it.
if _env_str("MONGO_TLS_FORCE_TLS12", "0") in ("1", "true", "yes", "on"):
cafile = kwargs.pop("tlsCAFile", None)
ctx = ssl.create_default_context(cafile=cafile)
try:
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
except Exception:
# Older Python/OpenSSL may not support TLSVersion fields.
pass
kwargs["ssl_context"] = ctx
# Optional escape hatch (NOT recommended): allow invalid certs for debugging only.
if _env_str("MONGO_TLS_INSECURE", "0") in ("1", "true", "yes", "on"):
kwargs["tlsAllowInvalidCertificates"] = True
return MongoClient(uri, **kwargs)
@lru_cache(maxsize=1)
def _mongo_client_cached() -> MongoClient:
# Reuse one client (connection pool) across requests.
return _mongo_client()
app = FastAPI()
# CORS: needed so the app can call the Python host's /api/video/session
_cors_origins_raw = _env_str("CORS_ALLOW_ORIGINS", "*")
_cors_origins = [o.strip() for o in _cors_origins_raw.split(",") if o.strip()]
_cors_allow_origin_regex = None
if len(_cors_origins) == 0 or (len(_cors_origins) == 1 and _cors_origins[0] == "*"):
# With credentials, browsers can't accept Access-Control-Allow-Origin: *
# Using a regex makes Starlette reflect the requesting Origin instead.
_cors_origins = []
_cors_allow_origin_regex = ".*"
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_origin_regex=_cors_allow_origin_regex,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.post("/api/video/session")
def create_video_session(request: Request, response: Response, authorization: Optional[str] = Header(default=None)):
token = _parse_bearer(authorization)
_verify_token(token)
secure = request.url.scheme == "https" or (request.headers.get("x-forwarded-proto") == "https")
encoded = quote(token, safe="")
cookie_max_age_seconds = int(_env_str("VIDEO_SESSION_MAX_AGE_SECONDS", "86400")) # 24h (longer default to avoid expiration during long movie sessions)
cookie_samesite = _env_str("VIDEO_COOKIE_SAMESITE", "Lax") # Lax|Strict|None
samesite = (cookie_samesite or "Lax").strip().lower()
if samesite not in ("lax", "strict", "none"):
samesite = "lax"
samesite_attr = "None" if samesite == "none" else ("Strict" if samesite == "strict" else "Lax")
cookie_parts = [
f"video_auth={encoded}",
"Path=/api/video",
f"Max-Age={cookie_max_age_seconds}",
f"SameSite={samesite_attr}",
"HttpOnly",
]
# Modern browsers require Secure when SameSite=None
if secure or samesite == "none":
cookie_parts.append("Secure")
response.headers["Set-Cookie"] = "; ".join(cookie_parts)
response.status_code = 204
return None
@app.api_route("/api/video/{movie_id}", methods=["GET", "HEAD"])
def stream_video(
movie_id: str,
request: Request,
authorization: Optional[str] = Header(default=None),
range: Optional[str] = Header(default=None), # noqa: A002
):
token = _get_token(request, authorization)
_verify_token(token)
try:
oid = ObjectId(movie_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid movie id")
db_name = _mongo_db_name()
if not db_name:
raise HTTPException(
status_code=500,
detail="Mongo DB name not configured. Add it to MONGO_URI (…/dbname) or set MONGO_DB_NAME.",
)
try:
client = _mongo_client_cached()
db = client[db_name]
movie = db["movies"].find_one({"_id": oid}, {"video_file": 1, "video_file_low": 1})
except PyMongoError as e:
raise HTTPException(
status_code=502,
detail=(
"Failed to query MongoDB (TLS/connection issue). "
"On some hosts you may need a CA bundle; try installing certifi or set MONGO_TLS_CA_FILE. "
f"Original error: {type(e).__name__}: {e}"
),
)
quality = (request.query_params.get("quality") or "").strip().lower()
wants_low = quality == "low"
video_file = (movie or {}).get("video_file") or ""
video_file_low = (movie or {}).get("video_file_low") or ""
chosen = video_file_low if wants_low else video_file
if not isinstance(chosen, str) or not chosen.strip():
if wants_low:
raise HTTPException(status_code=404, detail="No low-quality server video file configured for this movie")
raise HTTPException(status_code=404, detail="No server video file configured for this movie")
chosen = chosen.strip()
if not _is_safe_relative_file(chosen):
raise HTTPException(status_code=400, detail="Invalid video_file path")
video_dir = _video_files_dir()
full_path = (video_dir / chosen).resolve()
if not str(full_path).startswith(str(video_dir) + os.sep) and full_path != video_dir:
raise HTTPException(status_code=400, detail="Invalid video_file path")
if not full_path.exists() or not full_path.is_file():
raise HTTPException(status_code=404, detail="Video file not found on server")
stat = full_path.stat()
file_size = stat.st_size
content_type = _guess_content_type(full_path)
# Shared headers (match the Node server behavior closely)
base_headers = {
"Accept-Ranges": "bytes",
"Content-Type": content_type,
"Cache-Control": "private, max-age=0, must-revalidate",
}
if request.method == "HEAD":
headers = dict(base_headers)
headers["Content-Length"] = str(file_size)
return Response(status_code=200, headers=headers)
r = _parse_range(range or "", file_size)
if r is None and (range or ""):
# Invalid Range
headers = dict(base_headers)
headers["Content-Range"] = f"bytes */{file_size}"
return Response(status_code=416, headers=headers)
if r is None:
# Full file
return FileResponse(
path=str(full_path),
media_type=content_type,
headers=base_headers,
)
start, end = r
chunk_size = end - start + 1
headers = dict(base_headers)
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
headers["Content-Length"] = str(chunk_size)
return StreamingResponse(
_file_iter(full_path, start, end),
status_code=206,
media_type=content_type,
headers=headers,
)
@app.get("/healthz")
def healthz():
return {
"ok": True,
"time": datetime.now(timezone.utc).isoformat(),
"video_files_dir": str(_video_files_dir()),
"mongo_db_name": _mongo_db_name() or None,
"openssl": ssl.OPENSSL_VERSION,
}
@app.on_event("shutdown")
def _shutdown():
# Close the cached Mongo client cleanly.
try:
client = _mongo_client_cached()
client.close()
except Exception:
pass
+420
View File
@@ -0,0 +1,420 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
const readline = require("readline");
const multer = require("multer");
const { Transform } = require("stream");
// Use MongoDB Extended JSON to preserve ObjectId/Date/etc.
// bson is a transitive dependency of mongoose (mongodb driver).
let EJSON;
try {
({ EJSON } = require("bson"));
} catch (_) {
EJSON = null;
}
const router = express.Router();
const BACKUP_DIR = path.join(process.cwd(), "backups");
const MAX_UPLOAD_BYTES = 250 * 1024 * 1024; // 250MB
// SECURITY: protect against gzip "zip bombs" (small upload -> massive output).
// This caps the *decompressed* stream size during restore.
const MAX_RESTORE_UNZIPPED_BYTES = 1024 * 1024 * 1024; // 1GB
function createByteLimitStream(maxBytes) {
let seen = 0;
return new Transform({
transform(chunk, _enc, cb) {
seen += chunk.length;
if (seen > maxBytes) {
const err = new Error("Restore failed: decompressed data is too large");
// mimic our handler's error shape
err.statusCode = 413;
cb(err);
return;
}
cb(null, chunk);
},
});
}
function requireAdmin(req) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
const err = new Error("Authentication token is required");
err.statusCode = 401;
throw err;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded?.admin) {
const err = new Error("You do not have admin privileges");
err.statusCode = 403;
throw err;
}
return decoded;
} catch (e) {
if (e instanceof jwt.JsonWebTokenError) {
const err = new Error("Invalid or expired token");
err.statusCode = 401;
throw err;
}
throw e;
}
}
function safeBackupFileName(raw) {
const name = String(raw || "").trim();
// Only allow simple filenames to avoid traversal.
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return null;
if (!name.endsWith(".ndjson.gz")) return null;
return name;
}
async function ensureBackupDir() {
await fs.promises.mkdir(BACKUP_DIR, { recursive: true });
}
function nowStamp() {
return new Date().toISOString().replace(/[:.]/g, "-");
}
function backupFileNameFromDb(dbName) {
const safeDb = String(dbName || "db").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 60) || "db";
return `mongo-backup-${safeDb}-${nowStamp()}.ndjson.gz`;
}
function requireDbReady() {
if (!mongoose?.connection || mongoose.connection.readyState !== 1) {
const err = new Error("MongoDB connection is not ready");
err.statusCode = 503;
throw err;
}
if (!EJSON) {
const err = new Error("bson EJSON is not available in this environment");
err.statusCode = 500;
throw err;
}
}
const upload = multer({
storage: multer.diskStorage({
destination: async (req, file, cb) => {
try {
await ensureBackupDir();
cb(null, BACKUP_DIR);
} catch (e) {
cb(e);
}
},
filename: (req, file, cb) => {
// Keep uploads isolated; we rename during restore if needed.
const name = `upload-${nowStamp()}-${Math.random().toString(16).slice(2)}.ndjson.gz`;
cb(null, name);
},
}),
limits: { fileSize: MAX_UPLOAD_BYTES },
});
async function listBackupFiles() {
await ensureBackupDir();
const entries = await fs.promises.readdir(BACKUP_DIR, { withFileTypes: true });
const files = entries
.filter((e) => e.isFile())
.map((e) => e.name)
.filter((n) => safeBackupFileName(n))
.sort()
.reverse();
const out = [];
for (const name of files) {
const full = path.join(BACKUP_DIR, name);
try {
const stat = await fs.promises.stat(full);
out.push({
name,
sizeBytes: stat.size,
mtime: stat.mtime.toISOString(),
});
} catch (_) {
// ignore races
}
}
return out;
}
// Admin: list existing backups (metadata only)
router.get("/db/backups", async (req, res) => {
try {
requireAdmin(req);
// Admin + auth-scoped data: never allow caching.
res.setHeader("Cache-Control", "private, no-store, must-revalidate");
res.setHeader("Vary", "Authorization");
const backups = await listBackupFiles();
return res.status(200).json({ backups });
} catch (err) {
return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" });
}
});
// Admin: create a new backup and store it on the server
router.post("/db/backup", async (req, res) => {
try {
requireAdmin(req);
requireDbReady();
await ensureBackupDir();
const db = mongoose.connection.db;
const dbName = db?.databaseName || "db";
const fileName = backupFileNameFromDb(dbName);
const fullPath = path.join(BACKUP_DIR, fileName);
const gzip = zlib.createGzip({ level: 9 });
const fileOut = fs.createWriteStream(fullPath, { flags: "wx" });
gzip.pipe(fileOut);
function writeLine(obj) {
const line = EJSON.stringify(obj) + "\n";
return gzip.write(line);
}
// Header
writeLine({
type: "meta",
version: 1,
createdAt: new Date().toISOString(),
dbName,
format: "ndjson+gzip",
});
const collections = await db.listCollections().toArray();
const userCollections = collections
.map((c) => c?.name)
.filter(Boolean)
.filter((name) => !String(name).startsWith("system."));
for (const name of userCollections) {
writeLine({ type: "collection", name });
const cursor = db.collection(name).find({});
// Stream docs one-by-one (no giant in-memory array)
for await (const doc of cursor) {
writeLine({ type: "doc", collection: name, doc });
}
}
writeLine({ type: "end" });
gzip.end();
await new Promise((resolve, reject) => {
fileOut.on("finish", resolve);
fileOut.on("error", reject);
gzip.on("error", reject);
});
const stat = await fs.promises.stat(fullPath);
return res.status(201).json({
message: "Backup created",
backup: { name: fileName, sizeBytes: stat.size, createdAt: new Date().toISOString() },
});
} catch (err) {
return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" });
}
});
// Admin: download a specific backup
router.get("/db/backups/:name", async (req, res) => {
try {
requireAdmin(req);
res.setHeader("Cache-Control", "private, no-store, must-revalidate");
res.setHeader("Vary", "Authorization");
const safe = safeBackupFileName(req.params?.name);
if (!safe) return res.status(400).json({ message: "Invalid backup name" });
const full = path.join(BACKUP_DIR, safe);
await fs.promises.access(full, fs.constants.R_OK);
res.setHeader("Content-Type", "application/gzip");
res.setHeader("Content-Disposition", `attachment; filename="${safe}"`);
fs.createReadStream(full).pipe(res);
} catch (err) {
const code = err.code === "ENOENT" ? 404 : err.statusCode || 500;
return res.status(code).json({ message: err.message || "Internal server error" });
}
});
// Admin: delete a backup file
router.delete("/db/backups/:name", async (req, res) => {
try {
requireAdmin(req);
const safe = safeBackupFileName(req.params?.name);
if (!safe) return res.status(400).json({ message: "Invalid backup name" });
const full = path.join(BACKUP_DIR, safe);
await fs.promises.unlink(full);
return res.status(200).json({ message: "Backup deleted" });
} catch (err) {
const code = err.code === "ENOENT" ? 404 : err.statusCode || 500;
return res.status(code).json({ message: err.message || "Internal server error" });
}
});
// Admin: restore from an uploaded backup.
// Query: ?drop=true to drop collections before insert (recommended).
router.post("/db/restore", upload.single("backup"), async (req, res) => {
let uploadedPath = null;
try {
requireAdmin(req);
requireDbReady();
const drop = String(req.query?.drop || req.body?.drop || "").toLowerCase() === "true";
const file = req.file;
if (!file?.path) {
return res.status(400).json({ message: "No backup file uploaded" });
}
uploadedPath = file.path;
// Best-effort: refuse obviously wrong file names (UI expects .ndjson.gz).
const original = String(file.originalname || "").trim().toLowerCase();
if (original && !original.endsWith(".gz")) {
const err = new Error("Invalid backup file (expected a .gz file)");
err.statusCode = 400;
throw err;
}
const db = mongoose.connection.db;
const gunzip = zlib.createGunzip();
const byteLimit = createByteLimitStream(MAX_RESTORE_UNZIPPED_BYTES);
const input = fs.createReadStream(uploadedPath).pipe(gunzip).pipe(byteLimit);
const rl = readline.createInterface({ input, crlfDelay: Infinity });
// Ensure stream errors don't become unhandled.
let streamError = null;
input.on("error", (e) => {
streamError = e;
try {
rl.close();
} catch (_) {}
});
let meta = null;
let currentCollection = null;
let batch = [];
const dropped = new Set();
const insertedCountByCollection = new Map();
async function flushBatch() {
if (!currentCollection || batch.length === 0) return;
const coll = db.collection(currentCollection);
try {
const result = await coll.insertMany(batch, { ordered: false });
const inserted = result?.insertedCount || 0;
insertedCountByCollection.set(
currentCollection,
(insertedCountByCollection.get(currentCollection) || 0) + inserted
);
} catch (e) {
// If restoring into an existing DB without dropping, duplicates are likely.
// Surface a clear error instead of half-success.
const err = new Error(
`Restore failed while inserting into "${currentCollection}": ${e?.message || "insertMany error"}`
);
err.statusCode = 400;
throw err;
} finally {
batch = [];
}
}
async function ensureDropped(name) {
if (!drop) return;
if (dropped.has(name)) return;
dropped.add(name);
try {
await db.collection(name).drop();
} catch (e) {
// If collection doesn't exist, ignore.
if (e?.codeName !== "NamespaceNotFound" && e?.code !== 26) throw e;
}
}
for await (const line of rl) {
const trimmed = String(line || "").trim();
if (!trimmed) continue;
let obj;
try {
obj = EJSON.parse(trimmed);
} catch (_) {
const err = new Error("Invalid backup format (cannot parse JSON line)");
err.statusCode = 400;
throw err;
}
if (obj?.type === "meta") {
meta = obj;
continue;
}
if (obj?.type === "collection") {
await flushBatch();
currentCollection = String(obj?.name || "").trim();
if (!currentCollection || currentCollection.startsWith("system.")) {
currentCollection = null;
continue;
}
await ensureDropped(currentCollection);
continue;
}
if (obj?.type === "doc") {
const coll = String(obj?.collection || "").trim();
if (!coll || coll.startsWith("system.")) continue;
if (currentCollection !== coll) {
await flushBatch();
currentCollection = coll;
await ensureDropped(currentCollection);
}
if (obj?.doc && typeof obj.doc === "object") {
batch.push(obj.doc);
if (batch.length >= 500) {
await flushBatch();
}
}
continue;
}
}
await flushBatch();
if (streamError) {
throw streamError;
}
// Cleanup upload after successful restore
try {
await fs.promises.unlink(uploadedPath);
uploadedPath = null;
} catch (_) {}
return res.status(200).json({
message: "Restore completed",
meta,
drop,
inserted: Object.fromEntries(insertedCountByCollection.entries()),
});
} catch (err) {
// Best-effort cleanup
if (uploadedPath) {
try {
await fs.promises.unlink(uploadedPath);
} catch (_) {}
}
return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" });
}
});
module.exports = router;
+374
View File
@@ -0,0 +1,374 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const path = require("path");
const fs = require("fs");
const OscarCategory = require("../models/OscarCategory");
const OscarPick = require("../models/OscarPick");
const router = express.Router();
// Middleware to verify token
function verifyToken(req, res, next) {
const token = req.headers['authorization'] && req.headers['authorization'].startsWith('Bearer ')
? req.headers['authorization'].split(' ')[1]
: null;
if (!token) {
return res.status(403).json({ message: "No token provided, access denied." });
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return res.status(401).json({ message: "Invalid or expired token" });
}
req.user = decoded;
next();
});
}
function isAdmin(decoded) {
return !!decoded?.admin;
}
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
// Helper function to clean nominee name
function cleanNomineeName(line) {
return line.replace(/^[-☐•]\s*/g, '').replace(/^-\s*☐\s*/, '').trim();
}
// Helper function to check if a line is a valid nominee
function isValidNomineeLine(line, skipPatterns) {
// Skip if it looks like a category header (number first or name first with number)
if (line.match(/^(\d+)[\t\s]+(BEST|DOCUMENTARY|ANIMATED|LIVE|INTERNATIONAL|PRODUCTION|SOUND|MAKEUP|COSTUME|CINEMATOGRAPHY|ORIGINAL|ADAPTED|SCREENPLAY|FILM|EDITING|VISUAL|EFFECTS|SUPPORTING|ACTOR|ACTRESS|DIRECTOR|PICTURE)/i) ||
line.match(/^(.+?)[\t\s]+\d+$/) ||
line.match(/^\d+\./)) {
return false;
}
// Skip header/metadata lines
if (skipPatterns.test(line)) {
return false;
}
const nomineeName = cleanNomineeName(line);
if (!nomineeName || nomineeName.length === 0 || nomineeName.length >= 200) {
return false;
}
// Skip lines that are just numbers or dashes
if (nomineeName.match(/^-?\s*\d+\s*-?$/) || nomineeName.match(/^-+\s*$/)) {
return false;
}
const upperLine = nomineeName.toUpperCase();
// Skip if it looks like a header (but allow category names that might appear in nominees with commas)
const isHeader = upperLine.match(/^(YOUR|SCORE|NAME|COVERS|OSCARS|PARTY|PROPS|SHEET)$/) ||
(upperLine.match(/^(BEST|DOCUMENTARY|ANIMATED|LIVE|INTERNATIONAL|PRODUCTION|SOUND|MAKEUP|COSTUME|CINEMATOGRAPHY|ORIGINAL|ADAPTED|SCREENPLAY|FILM|EDITING|VISUAL|EFFECTS|SUPPORTING|ACTOR|ACTRESS|DIRECTOR|PICTURE)$/) &&
!nomineeName.includes(',') && nomineeName.length < 30);
return !isHeader;
}
// Manually create/update categories (admin only)
router.post("/create", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { year, categoryNumber, categoryName, nominees } = req.body;
const yearNum = parseOscarYear(year) || new Date().getFullYear();
if (!categoryNumber || !categoryName || !Array.isArray(nominees) || nominees.length === 0) {
return res.status(400).json({ message: "categoryNumber, categoryName, and nominees array are required" });
}
const nomineesArray = nominees.map(n => ({
name: typeof n === 'string' ? n : n.name,
isWinner: typeof n === 'object' ? (n.isWinner || false) : false
}));
const category = await OscarCategory.findOneAndUpdate(
{ year: yearNum, categoryNumber },
{
year: yearNum,
categoryNumber,
categoryName,
nominees: nomineesArray
},
{ upsert: true, new: true }
);
res.json({ message: "Category saved successfully", category });
} catch (err) {
console.error("Error creating category:", err);
res.status(500).json({ error: err.message });
}
});
// Get all categories for a year (admin view with management)
router.get("/list", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const year = parseOscarYear(req.query.year) || new Date().getFullYear();
const categories = await OscarCategory.find({ year })
.sort({ categoryNumber: 1 })
.lean();
res.json({ year, categories });
} catch (err) {
console.error("Error fetching categories:", err);
res.status(500).json({ error: err.message });
}
});
// Get a single category by ID (admin only) - must come after /list route
router.get("/:categoryId", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const category = await OscarCategory.findById(req.params.categoryId).lean();
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
res.json({ category });
} catch (err) {
console.error("Error fetching category:", err);
res.status(500).json({ error: err.message });
}
});
// Update a category (admin only)
router.put("/:categoryId", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { categoryNumber, categoryName, nominees } = req.body;
if (!categoryName || !Array.isArray(nominees) || nominees.length === 0) {
return res.status(400).json({ message: "categoryName and nominees array are required" });
}
if (categoryNumber !== undefined && categoryNumber !== null) {
const categoryNum = parseInt(categoryNumber);
if (!Number.isInteger(categoryNum) || categoryNum < 1 || categoryNum > 50) {
return res.status(400).json({ message: "categoryNumber must be between 1 and 50" });
}
}
const nomineesArray = nominees.map(n => ({
name: typeof n === 'string' ? n : n.name,
isWinner: typeof n === 'object' ? (n.isWinner || false) : false
}));
// Get the current category to check year and handle categoryNumber conflicts
const currentCategory = await OscarCategory.findById(req.params.categoryId);
if (!currentCategory) {
return res.status(404).json({ message: "Category not found" });
}
const year = currentCategory.year;
const updateData = {
categoryName,
nominees: nomineesArray
};
// If categoryNumber is being changed, check for conflicts and update existing picks
if (categoryNumber !== undefined && categoryNumber !== null) {
const newCategoryNum = parseInt(categoryNumber);
if (newCategoryNum !== currentCategory.categoryNumber) {
const oldCategoryNum = currentCategory.categoryNumber;
// Check if another category with this number already exists for this year
const existingCategory = await OscarCategory.findOne({
year,
categoryNumber: newCategoryNum,
_id: { $ne: req.params.categoryId }
});
if (existingCategory) {
return res.status(400).json({
message: `Category number ${newCategoryNum} already exists for year ${year}`
});
}
// Update all existing picks that reference the old category number
// Match by old categoryNumber and old categoryName to ensure we're updating the right picks
const picksToUpdate = await OscarPick.find({ year });
let updatedPicksCount = 0;
for (const pick of picksToUpdate) {
if (pick.picks && Array.isArray(pick.picks)) {
let needsUpdate = false;
const updatedPicks = pick.picks.map(p => {
// Match by both old categoryNumber and old categoryName to be safe
if (p.categoryNumber === oldCategoryNum && p.categoryName === currentCategory.categoryName) {
needsUpdate = true;
return {
...p,
categoryNumber: newCategoryNum,
categoryName: categoryName // Update categoryName too if it changed
};
}
return p;
});
if (needsUpdate) {
pick.picks = updatedPicks;
await pick.save();
updatedPicksCount++;
}
}
}
updateData.categoryNumber = newCategoryNum;
// Log the update (optional, for debugging)
if (updatedPicksCount > 0) {
console.log(`Updated ${updatedPicksCount} pick(s) when changing category number from ${oldCategoryNum} to ${newCategoryNum}`);
}
}
}
const category = await OscarCategory.findByIdAndUpdate(
req.params.categoryId,
updateData,
{ new: true }
);
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
res.json({ message: "Category updated successfully", category });
} catch (err) {
console.error("Error updating category:", err);
res.status(500).json({ error: err.message });
}
});
// Mark winner for a category (admin only)
router.put("/:categoryId/winner", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { winnerName } = req.body;
const isClearing = !winnerName || winnerName === '' || winnerName === null;
const category = await OscarCategory.findById(req.params.categoryId);
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
if (isClearing) {
// Clear all winners (set all nominees to not winners)
category.nominees = category.nominees.map(n => ({
...n.toObject(),
isWinner: false
}));
await category.save();
return res.json({ message: "Winner cleared successfully", category });
}
// Mark all nominees as not winners, then mark the selected one as winner
category.nominees = category.nominees.map(n => ({
...n.toObject(),
isWinner: n.name === winnerName
}));
await category.save();
res.json({ message: "Winner marked successfully", category });
} catch (err) {
console.error("Error marking winner:", err);
res.status(500).json({ error: err.message });
}
});
// Helper function to clean up picks for deleted categories
async function cleanupPicksForDeletedCategories(year) {
try {
const categories = await OscarCategory.find({ year }).lean();
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
const allPicks = await OscarPick.find({ year });
let totalCleaned = 0;
for (const pick of allPicks) {
if (pick.picks && Array.isArray(pick.picks)) {
const originalLength = pick.picks.length;
pick.picks = pick.picks.filter(p => {
const category = categoryMap.get(p.categoryNumber);
if (!category) return false; // Category was deleted
// Also check if nominee still exists in category
return category.nominees.some(n => n.name === p.selectedNominee);
});
if (pick.picks.length !== originalLength) {
await pick.save();
totalCleaned += (originalLength - pick.picks.length);
}
}
}
if (totalCleaned > 0) {
console.log(`Cleaned up ${totalCleaned} pick(s) for deleted categories in year ${year}`);
}
return totalCleaned;
} catch (err) {
console.error("Error cleaning up picks:", err);
throw err;
}
}
// Delete a category (admin only)
router.delete("/:categoryId", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const category = await OscarCategory.findById(req.params.categoryId);
if (!category) {
return res.status(404).json({ message: "Category not found" });
}
const year = category.year;
// Delete the category
await OscarCategory.findByIdAndDelete(req.params.categoryId);
// Clean up picks for deleted categories
await cleanupPicksForDeletedCategories(year);
res.json({ message: "Category deleted successfully" });
} catch (err) {
console.error("Error deleting category:", err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+1246 -8
View File
File diff suppressed because it is too large Load Diff
+381
View File
@@ -0,0 +1,381 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const OscarCategory = require("../models/OscarCategory");
const OscarPick = require("../models/OscarPick");
const User = require("../models/User");
const Setting = require("../models/Setting");
const router = express.Router();
const ACTIVE_YEAR_KEY = "active_oscar_year";
async function getOrInitActiveYear() {
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
if (existing && typeof existing.value === "number") return existing.value;
return new Date().getFullYear();
}
// Middleware to verify token
function verifyToken(req, res, next) {
const token = req.headers['authorization'] && req.headers['authorization'].startsWith('Bearer ')
? req.headers['authorization'].split(' ')[1]
: null;
if (!token) {
return res.status(403).json({ message: "No token provided, access denied." });
}
jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return res.status(401).json({ message: "Invalid or expired token" });
}
req.user = decoded;
next();
});
}
function isAdmin(decoded) {
return !!decoded?.admin;
}
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
// Get all categories for a year (for users to see what to pick)
router.get("/categories", verifyToken, async (req, res) => {
try {
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
const categories = await OscarCategory.find({ year })
.sort({ categoryNumber: 1 })
.lean();
res.json({ year, categories });
} catch (err) {
console.error("Error fetching categories:", err);
res.status(500).json({ error: err.message });
}
});
// Submit picks (user)
router.post("/submit", verifyToken, async (req, res) => {
try {
const { year, picks } = req.body;
const userId = req.user.id;
const yearNum = parseOscarYear(year) || await getOrInitActiveYear();
if (!Array.isArray(picks)) {
return res.status(400).json({ message: "Picks must be an array" });
}
// Verify categories exist for this year
const categories = await OscarCategory.find({ year: yearNum });
if (categories.length === 0) {
return res.status(400).json({ message: "No categories found for this year. Please import categories first." });
}
// Filter and validate picks - remove picks for deleted categories
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
const validPicks = [];
if (picks.length > 0) {
for (const pick of picks) {
if (!pick.categoryNumber || !pick.selectedNominee) {
continue; // Skip invalid picks
}
const category = categoryMap.get(pick.categoryNumber);
if (!category) {
// Category was deleted - skip this pick
continue;
}
const nomineeExists = category.nominees.some(n => n.name === pick.selectedNominee);
if (!nomineeExists) {
// Nominee doesn't exist in this category - skip this pick
continue;
}
validPicks.push({
categoryNumber: pick.categoryNumber,
categoryName: category.categoryName,
selectedNominee: pick.selectedNominee
});
}
}
// Create or update picks (only with valid picks)
const pickData = {
userId,
year: yearNum,
picks: validPicks
};
const existingPick = await OscarPick.findOne({ userId, year: yearNum });
if (existingPick) {
existingPick.picks = pickData.picks;
existingPick.submittedAt = new Date();
await existingPick.save();
return res.json({ message: "Picks updated successfully", pick: existingPick });
} else {
const newPick = new OscarPick(pickData);
await newPick.save();
return res.json({ message: "Picks submitted successfully", pick: newPick });
}
} catch (err) {
console.error("Error submitting picks:", err);
res.status(500).json({ error: err.message });
}
});
// Get user's own picks
router.get("/my-picks", verifyToken, async (req, res) => {
try {
const userId = req.user.id;
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
const pick = await OscarPick.findOne({ userId, year });
if (!pick) {
return res.json({ pick: null, year });
}
// Clean up picks for deleted categories
const categories = await OscarCategory.find({ year }).lean();
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
if (pick.picks && Array.isArray(pick.picks)) {
const originalLength = pick.picks.length;
pick.picks = pick.picks.filter(p => {
const category = categoryMap.get(p.categoryNumber);
if (!category) return false; // Category was deleted
// Also check if nominee still exists in category
return category.nominees.some(n => n.name === p.selectedNominee);
});
// If picks were cleaned up, save the updated pick
if (pick.picks.length !== originalLength) {
await pick.save();
}
}
res.json({ pick: pick.toObject(), year });
} catch (err) {
console.error("Error fetching user picks:", err);
res.status(500).json({ error: err.message });
}
});
// Get all picks (admin only) - for viewing all users' picks
router.get("/all", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
const picks = await OscarPick.find({ year })
.populate('userId', 'name email')
.sort({ submittedAt: -1 })
.lean();
// Also get categories to show nominee options
const categories = await OscarCategory.find({ year })
.sort({ categoryNumber: 1 })
.lean();
res.json({ year, picks, categories });
} catch (err) {
console.error("Error fetching all picks:", err);
res.status(500).json({ error: err.message });
}
});
// Calculate scores for all users (admin only)
router.post("/calculate-scores", verifyToken, async (req, res) => {
try {
if (!isAdmin(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { year } = req.body;
const yearNum = parseOscarYear(year) || await getOrInitActiveYear();
// Get all categories with winners marked
const categories = await OscarCategory.find({ year: yearNum }).lean();
const categoryWinners = new Map();
categories.forEach(cat => {
const winner = cat.nominees.find(n => n.isWinner);
if (winner) {
categoryWinners.set(cat.categoryNumber, winner.name);
}
});
if (categoryWinners.size === 0) {
return res.status(400).json({ message: "No winners marked for any category. Please mark winners first." });
}
// Get all picks for this year with user info
const allPicks = await OscarPick.find({ year: yearNum })
.populate('userId', 'name email')
.lean();
// Create a map of all categories for reference
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
// Calculate scores
const scores = [];
for (const pick of allPicks) {
let correctCount = 0;
const pickDetails = [];
// Only process picks for categories that still exist
pick.picks.forEach(p => {
const category = categoryMap.get(p.categoryNumber);
if (!category) {
// Category was deleted - skip this pick
return;
}
const correctWinner = categoryWinners.get(p.categoryNumber);
const isCorrect = correctWinner && p.selectedNominee === correctWinner;
if (isCorrect) correctCount++;
pickDetails.push({
categoryNumber: p.categoryNumber,
categoryName: p.categoryName,
selectedNominee: p.selectedNominee,
correctWinner: correctWinner || null,
isCorrect
});
});
// Update the pick with score and clean up deleted category picks
const validPicks = pick.picks.filter(p => categoryMap.has(p.categoryNumber));
await OscarPick.findByIdAndUpdate(pick._id, {
score: correctCount,
picks: validPicks
});
scores.push({
userId: pick.userId?._id || pick.userId,
userName: pick.userId?.name || 'Unknown',
userEmail: pick.userId?.email || '',
score: correctCount,
totalCategories: categories.length,
pickDetails
});
}
// Sort by score (descending)
scores.sort((a, b) => b.score - a.score);
res.json({
message: "Scores calculated successfully",
year: yearNum,
scores,
totalCategories: categories.length,
categoriesWithWinners: categoryWinners.size
});
} catch (err) {
console.error("Error calculating scores:", err);
res.status(500).json({ error: err.message });
}
});
// Get scores for all users (available to all authenticated users)
router.get("/scores", verifyToken, async (req, res) => {
try {
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
// Clean up picks for deleted categories before fetching scores
const categories = await OscarCategory.find({ year }).lean();
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
const picksToClean = await OscarPick.find({ year });
for (const pick of picksToClean) {
if (pick.picks && Array.isArray(pick.picks)) {
const originalLength = pick.picks.length;
pick.picks = pick.picks.filter(p => {
const category = categoryMap.get(p.categoryNumber);
if (!category) return false; // Category was deleted
// Also check if nominee still exists in category
return category.nominees.some(n => n.name === p.selectedNominee);
});
if (pick.picks.length !== originalLength) {
await pick.save();
}
}
}
const picks = await OscarPick.find({ year })
.populate('userId', 'name email')
.sort({ score: -1, submittedAt: 1 })
.lean();
// Reuse categories already fetched for cleanup
const categoryWinners = new Map();
categories.forEach(cat => {
const winner = cat.nominees.find(n => n.isWinner);
if (winner) {
categoryWinners.set(cat.categoryNumber, winner.name);
}
});
const scores = picks.map(pick => {
let correctCount = 0;
const pickDetails = [];
// Create a map of all categories for reference
const categoryMap = new Map(categories.map(c => [c.categoryNumber, c]));
// Only process picks for categories that still exist
pick.picks.forEach(p => {
const category = categoryMap.get(p.categoryNumber);
if (!category) {
// Category was deleted - skip this pick
return;
}
const correctWinner = categoryWinners.get(p.categoryNumber);
const isCorrect = correctWinner && p.selectedNominee === correctWinner;
if (isCorrect) correctCount++;
const allNominees = category.nominees.map(n => n.name);
pickDetails.push({
categoryNumber: p.categoryNumber,
categoryName: p.categoryName,
selectedNominee: p.selectedNominee,
correctWinner: correctWinner || null,
isCorrect,
allNominees: allNominees
});
});
return {
userId: pick.userId?._id || pick.userId,
userName: pick.userId?.name || 'Unknown',
userEmail: pick.userId?.email || '',
score: correctCount, // Always use the calculated score from existing categories
totalCategories: categories.length,
pickDetails,
submittedAt: pick.submittedAt
};
});
scores.sort((a, b) => b.score - a.score);
res.json({ year, scores, totalCategories: categories.length, categoriesWithWinners: categoryWinners.size });
} catch (err) {
console.error("Error fetching scores:", err);
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+905
View File
@@ -0,0 +1,905 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
const Setting = require("../models/Setting");
const Movie = require("../models/Movie");
const User = require("../models/User");
const router = express.Router();
const ACTIVE_YEAR_KEY = "active_oscar_year";
const COMPLETION_MODAL_KEY = "completion_modal_content";
const WINNERS_BY_YEAR_KEY = "winners_by_year";
const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year";
const APP_VERSION_KEY = "app_version_control";
const PLAYER_ADMIN_STATUS_UI_KEY = "player_admin_status_ui";
const POINTS_CONFIG_KEY = "points_config";
const VISIBILITY_CONFIG_KEY = "visibility_config";
const LANGUAGE_KEY = "site_language";
const crypto = require("crypto");
const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: "Félicitations! very nice 🎉🎉🎉",
bodyText: "",
videoSrc: "video/reward.mp4",
bodyHtml: "",
});
const DEFAULT_PLAYER_ADMIN_STATUS_UI = Object.freeze({
// Controls the admin-only debug/status block in player.html
showSource: true,
showProgress: true,
});
const DEFAULT_POINTS_CONFIG = Object.freeze({
pointsPerMovie: 1,
pointsPerCorrectPick: 1,
});
const DEFAULT_VISIBILITY_CONFIG = Object.freeze({
showPicksButton: false,
showBonPicksColumn: false,
showVersionTab: true,
});
const DEFAULT_LANGUAGE = "en";
const SUPPORTED_LANGUAGES = Object.freeze(["en", "fr"]);
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
function parseOptionalPoints(raw) {
if (raw === undefined) return null;
if (raw === null) return null;
if (raw === "") return null;
const n = Number(raw);
if (!Number.isFinite(n)) return null;
// Points can be any finite number, but we store a rounded int for consistency.
return Math.round(n);
}
function parseIsoDateString(raw) {
// Expected format: YYYY-MM-DD
if (raw === undefined || raw === null) return null;
const s = String(raw).trim();
if (!s) return null;
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
if (!Number.isInteger(year) || year < 1900 || year > 3000) return null;
if (!Number.isInteger(month) || month < 1 || month > 12) return null;
if (!Number.isInteger(day) || day < 1 || day > 31) return null;
// Validate calendar date (handles month/day overflow like Feb 30).
const d = new Date(Date.UTC(year, month - 1, day));
if (
d.getUTCFullYear() !== year ||
d.getUTCMonth() !== month - 1 ||
d.getUTCDate() !== day
) {
return null;
}
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function normalizeOscarDatesByYear(rawValue) {
// Stored format: { "2026": "2026-03-15" }
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const out = {};
for (const [k, v] of Object.entries(value)) {
const year = parseOscarYear(k);
if (!year) continue;
const date = parseIsoDateString(v);
if (!date) continue;
out[String(year)] = date;
}
return out;
}
function normalizeWinnersByYear(rawValue) {
// Stored format:
// - New: { "2026": [ { userId, points? }, { userId, points? } ] }
// - Legacy: { "2026": { userId, points? } }
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const out = {};
for (const [k, v] of Object.entries(value)) {
const year = parseOscarYear(k);
if (!year) continue;
const list = Array.isArray(v) ? v : v && typeof v === "object" ? [v] : [];
const dedup = new Map(); // userId -> {userId, points}
for (const rawEntry of list) {
const entry = rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {};
const userId = typeof entry.userId === "string" ? entry.userId.trim() : "";
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) continue;
const points = parseOptionalPoints(entry.points);
dedup.set(String(userId), { userId: String(userId), points });
}
const normalizedList = Array.from(dedup.values());
if (normalizedList.length) {
out[String(year)] = normalizedList;
}
}
return out;
}
function sanitizeHtml(raw) {
// Basic, defensive sanitization (admin-controlled input, rendered for all users).
// We remove script tags and inline event handlers. This is not a complete HTML sanitizer.
const html = String(raw || "");
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, "");
const withoutOnAttrs = withoutScripts
.replace(/\son\w+="[^"]*"/gi, "")
.replace(/\son\w+='[^']*'/gi, "")
.replace(/\son\w+=\S+/gi, "");
return withoutOnAttrs.trim();
}
function normalizeCompletionModal(rawValue) {
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const title = typeof value.title === "string" ? value.title : DEFAULT_COMPLETION_MODAL.title;
const bodyText = typeof value.bodyText === "string" ? value.bodyText : DEFAULT_COMPLETION_MODAL.bodyText;
const videoSrc = typeof value.videoSrc === "string" ? value.videoSrc : DEFAULT_COMPLETION_MODAL.videoSrc;
const bodyHtml = typeof value.bodyHtml === "string" ? value.bodyHtml : DEFAULT_COMPLETION_MODAL.bodyHtml;
return {
title: String(title || "").slice(0, 200).trim() || DEFAULT_COMPLETION_MODAL.title,
bodyText: String(bodyText || "").slice(0, 8000),
videoSrc: String(videoSrc || "").slice(0, 2048).trim(),
bodyHtml: sanitizeHtml(String(bodyHtml || "").slice(0, 20000)),
};
}
function normalizePlayerAdminStatusUi(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const showSource = v.showSource === undefined ? DEFAULT_PLAYER_ADMIN_STATUS_UI.showSource : !!v.showSource;
const showProgress = v.showProgress === undefined ? DEFAULT_PLAYER_ADMIN_STATUS_UI.showProgress : !!v.showProgress;
return { showSource, showProgress };
}
function createId() {
try {
// Node 16+ / 18+: randomUUID
if (crypto && typeof crypto.randomUUID === "function") return crypto.randomUUID();
} catch (_) {}
// Fallback: time-based
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function normalizeAppVersionControl(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const versions = Array.isArray(v.versions) ? v.versions : [];
const cleaned = versions
.map((e) => (e && typeof e === "object" && !Array.isArray(e) ? e : {}))
.map((e) => {
const id = typeof e.id === "string" ? e.id.trim() : "";
const version = typeof e.version === "string" ? e.version.trim() : "";
const message = typeof e.message === "string" ? e.message : "";
const dateISO = typeof e.dateISO === "string" ? e.dateISO.trim() : "";
const d = new Date(dateISO);
const safeDateISO = dateISO && !Number.isNaN(d.getTime()) ? d.toISOString() : null;
return {
id: id || null,
version: version ? version.slice(0, 80) : null,
message: String(message || "").slice(0, 500),
dateISO: safeDateISO,
};
})
.filter((e) => e.id && e.version)
.sort((a, b) => {
const ad = a.dateISO ? new Date(a.dateISO).getTime() : 0;
const bd = b.dateISO ? new Date(b.dateISO).getTime() : 0;
return bd - ad;
});
const activeId = typeof v.activeId === "string" ? v.activeId.trim() : "";
const active = cleaned.find((e) => String(e.id) === String(activeId)) || null;
return {
activeId: active ? String(active.id) : cleaned[0]?.id ? String(cleaned[0].id) : null,
versions: cleaned,
};
}
async function getOrInitActiveYear() {
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
if (existing && typeof existing.value === "number") return existing.value;
// Initialize from latest movie year if available, else from current year.
const latestMovie = await Movie.findOne({ year: { $type: "number" } })
.sort({ year: -1 })
.select("year");
const fallbackYear = latestMovie?.year || new Date().getFullYear();
const year = parseOscarYear(fallbackYear) || new Date().getFullYear();
await Setting.findOneAndUpdate(
{ key: ACTIVE_YEAR_KEY },
{ $set: { value: year } },
{ upsert: true, new: true }
);
return year;
}
// Public: get current active Oscar year used by the site.
router.get("/year", async (req, res) => {
try {
const year = await getOrInitActiveYear();
return res.status(200).json({ year });
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Public: get Oscar date for a given year (with fallback).
// - If no custom date is configured, fallback is March 15 of that year (legacy behavior).
router.get("/oscar-date", async (req, res) => {
try {
const year = parseOscarYear(req.query?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
const configured = dates[String(year)] || null;
const effectiveDate = configured || `${String(year)}-03-15`;
return res.status(200).json({
year,
date: configured, // configured value (null if unset)
effectiveDate, // always present
});
} catch (err) {
// Even if DB is down, keep the legacy fallback.
const year = parseOscarYear(req.query?.year);
if (year) {
return res.status(200).json({ year, date: null, effectiveDate: `${String(year)}-03-15` });
}
return res.status(500).json({ error: err.message });
}
});
// Public: get all configured Oscar dates.
router.get("/oscar-dates", async (req, res) => {
try {
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
return res.status(200).json({ dates });
} catch (err) {
return res.status(200).json({ dates: {} });
}
});
// Admin-only: set/clear Oscar date for a given year.
// Body: { date: "YYYY-MM-DD" } or { date: "" } to clear.
router.put("/oscar-date/:year", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const year = parseOscarYear(req.params?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const rawDate = req.body?.date;
const date = parseIsoDateString(rawDate);
const wantsClear = rawDate === "" || rawDate === null || rawDate === undefined || (typeof rawDate === "string" && rawDate.trim() === "");
if (!wantsClear && !date) {
return res.status(400).json({ message: "Date invalide (format attendu: YYYY-MM-DD)" });
}
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
if (wantsClear) {
delete dates[String(year)];
} else {
dates[String(year)] = date;
}
await Setting.findOneAndUpdate(
{ key: OSCAR_DATES_BY_YEAR_KEY },
{ $set: { value: dates } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, date: dates[String(year)] || null });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Admin-only: set current active Oscar year.
router.put("/year", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const year = parseOscarYear(req.body?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const updated = await Setting.findOneAndUpdate(
{ key: ACTIVE_YEAR_KEY },
{ $set: { value: year } },
{ upsert: true, new: true }
);
return res.status(200).json({ year: updated.value });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Public: get the "100% completion" modal content for checklist.
router.get("/completion-modal", async (req, res) => {
try {
const existing = await Setting.findOne({ key: COMPLETION_MODAL_KEY }).select("value");
const normalized = normalizeCompletionModal(existing?.value);
return res.status(200).json(normalized);
} catch (err) {
// If DB is down, still return a sane default so the client can render.
return res.status(200).json(DEFAULT_COMPLETION_MODAL);
}
});
// Admin-only: set the "100% completion" modal content for checklist.
router.put("/completion-modal", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const normalized = normalizeCompletionModal(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: COMPLETION_MODAL_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizeCompletionModal(updated?.value));
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Public: get winners by year (optionally with points)
router.get("/winners", async (req, res) => {
try {
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
const normalized = normalizeWinnersByYear(existing?.value);
const entries = Object.entries(normalized)
.flatMap(([yearStr, list]) =>
(Array.isArray(list) ? list : []).map((v) => ({
year: Number(yearStr),
userId: v.userId,
points: v.points ?? null,
}))
)
.filter((e) => parseOscarYear(e.year))
.sort((a, b) => b.year - a.year);
const userIds = entries.map((e) => e.userId).filter(Boolean);
const users = userIds.length
? await User.find({ _id: { $in: userIds } }).select("name").lean()
: [];
const nameById = new Map(users.map((u) => [String(u._id), u.name]));
return res.status(200).json({
winners: entries.map((e) => ({
year: e.year,
userId: e.userId,
name: nameById.get(String(e.userId)) || null,
points: e.points ?? null,
})),
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Public: get app version control (active + list)
router.get("/app-version", async (req, res) => {
try {
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const active = control.activeId
? control.versions.find((v) => String(v.id) === String(control.activeId)) || null
: null;
return res.status(200).json({ active, versions: control.versions });
} catch (_) {
return res.status(200).json({ active: null, versions: [] });
}
});
// Public: get player admin status UI flags (source/progress visibility)
router.get("/player-admin-status-ui", async (req, res) => {
try {
const existing = await Setting.findOne({ key: PLAYER_ADMIN_STATUS_UI_KEY }).select("value");
const normalized = normalizePlayerAdminStatusUi(existing?.value);
return res.status(200).json(normalized);
} catch (_) {
return res.status(200).json(DEFAULT_PLAYER_ADMIN_STATUS_UI);
}
});
// Admin-only: update player admin status UI flags
// Body: { showSource?: boolean, showProgress?: boolean }
router.put("/player-admin-status-ui", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const normalized = normalizePlayerAdminStatusUi(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: PLAYER_ADMIN_STATUS_UI_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizePlayerAdminStatusUi(updated?.value));
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Admin-only: create a new version entry (optionally activates if none or if activate=true)
// Body: { version: "1.2.3", message?: "..." }
router.post("/app-version", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const version = typeof req.body?.version === "string" ? req.body.version.trim() : "";
const message = typeof req.body?.message === "string" ? req.body.message : "";
if (!version) return res.status(400).json({ message: "Version requise (ex: 1.0.0)" });
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const entry = {
id: createId(),
version: version.slice(0, 80),
message: String(message || "").slice(0, 500),
dateISO: new Date().toISOString(),
};
const next = {
activeId: control.activeId,
versions: [entry, ...(Array.isArray(control.versions) ? control.versions : [])],
};
const wantsActivate = String(req.query?.activate || "").toLowerCase() === "true";
if (!next.activeId || wantsActivate) {
next.activeId = entry.id;
}
const updated = await Setting.findOneAndUpdate(
{ key: APP_VERSION_KEY },
{ $set: { value: next } },
{ upsert: true, new: true }
).select("value");
const normalized = normalizeAppVersionControl(updated?.value);
const active = normalized.activeId
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
: null;
return res.status(200).json({ active, versions: normalized.versions });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Admin-only: set active version by id
// Body: { id: "<versionId>" }
router.put("/app-version/active", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const id = typeof req.body?.id === "string" ? req.body.id.trim() : "";
if (!id) return res.status(400).json({ message: "id requis" });
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const exists = control.versions.find((v) => String(v.id) === String(id));
if (!exists) return res.status(404).json({ message: "Version introuvable" });
const next = { activeId: String(id), versions: control.versions };
const updated = await Setting.findOneAndUpdate(
{ key: APP_VERSION_KEY },
{ $set: { value: next } },
{ upsert: true, new: true }
).select("value");
const normalized = normalizeAppVersionControl(updated?.value);
const active = normalized.activeId
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
: null;
return res.status(200).json({ active, versions: normalized.versions });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Admin-only: delete a version entry by id (re-picks active if needed)
router.delete("/app-version/:id", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const id = typeof req.params?.id === "string" ? req.params.id.trim() : "";
if (!id) return res.status(400).json({ message: "id requis" });
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const nextVersions = control.versions.filter((v) => String(v.id) !== String(id));
const next = {
activeId: control.activeId && String(control.activeId) === String(id) ? null : control.activeId,
versions: nextVersions,
};
if (!next.activeId && nextVersions.length) next.activeId = String(nextVersions[0].id);
const updated = await Setting.findOneAndUpdate(
{ key: APP_VERSION_KEY },
{ $set: { value: next } },
{ upsert: true, new: true }
).select("value");
const normalized = normalizeAppVersionControl(updated?.value);
const active = normalized.activeId
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
: null;
return res.status(200).json({ active, versions: normalized.versions });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Admin-only: add/upsert/clear winners for a given year. Points optional.
// - Clear all for year: { userId: "" } (legacy behavior)
// - Add/upsert winner for year: { userId: "<mongoId>", points?: number|null }
// - Replace full list for year: { winners: [{ userId, points? }, ...] }
router.put("/winners/:year", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const year = parseOscarYear(req.params?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
const winners = normalizeWinnersByYear(existing?.value);
// Replace list mode: { winners: [...] }
if (Array.isArray(req.body?.winners)) {
const replacement = normalizeWinnersByYear({ [String(year)]: req.body.winners })[String(year)] || [];
if (!replacement.length) {
delete winners[String(year)];
} else {
winners[String(year)] = replacement;
}
await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY },
{ $set: { value: winners } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, winners: winners[String(year)] || [] });
}
// Add/upsert/clear mode (legacy-compatible): { userId, points? }
const rawUserId = req.body?.userId;
const userId = typeof rawUserId === "string" ? rawUserId.trim() : rawUserId === null ? "" : "";
const points = parseOptionalPoints(req.body?.points);
// Clear all for year
if (!userId) {
delete winners[String(year)];
await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY },
{ $set: { value: winners } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, winners: [] });
}
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.status(400).json({ message: "userId invalide" });
}
const winnerUser = await User.findById(userId).select("name").lean();
if (!winnerUser) return res.status(404).json({ message: "User not found" });
const key = String(year);
const current = Array.isArray(winners[key]) ? winners[key] : [];
const next = current.filter((w) => String(w?.userId || "") !== String(userId));
next.push({ userId: String(userId), points });
winners[key] = next;
await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY },
{ $set: { value: winners } },
{ upsert: true, new: true }
);
return res.status(200).json({
year,
winner: { year, userId: String(userId), name: winnerUser.name || null, points: points ?? null },
winners: winners[String(year)] || [],
});
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Admin-only: remove a specific winner for a given year.
router.delete("/winners/:year/:userId", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const year = parseOscarYear(req.params?.year);
if (!year) return res.status(400).json({ message: "Année invalide (ex: 2026)" });
const userId = typeof req.params?.userId === "string" ? req.params.userId.trim() : "";
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.status(400).json({ message: "userId invalide" });
}
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
const winners = normalizeWinnersByYear(existing?.value);
const key = String(year);
const current = Array.isArray(winners[key]) ? winners[key] : [];
const next = current.filter((w) => String(w?.userId || "") !== String(userId));
if (next.length) winners[key] = next;
else delete winners[key];
await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY },
{ $set: { value: winners } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, winners: winners[key] || [] });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
function normalizePointsConfig(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const pointsPerMovie = typeof v.pointsPerMovie === "number" && v.pointsPerMovie >= 0 ? Math.round(v.pointsPerMovie) : DEFAULT_POINTS_CONFIG.pointsPerMovie;
const pointsPerCorrectPick = typeof v.pointsPerCorrectPick === "number" && v.pointsPerCorrectPick >= 0 ? Math.round(v.pointsPerCorrectPick) : DEFAULT_POINTS_CONFIG.pointsPerCorrectPick;
return { pointsPerMovie, pointsPerCorrectPick };
}
function normalizeVisibilityConfig(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const showPicksButton = typeof v.showPicksButton === "boolean" ? v.showPicksButton : DEFAULT_VISIBILITY_CONFIG.showPicksButton;
const showBonPicksColumn = typeof v.showBonPicksColumn === "boolean" ? v.showBonPicksColumn : DEFAULT_VISIBILITY_CONFIG.showBonPicksColumn;
const showVersionTab = typeof v.showVersionTab === "boolean" ? v.showVersionTab : DEFAULT_VISIBILITY_CONFIG.showVersionTab;
return { showPicksButton, showBonPicksColumn, showVersionTab };
}
// Public: get points configuration
router.get("/points-config", async (req, res) => {
try {
const existing = await Setting.findOne({ key: POINTS_CONFIG_KEY }).select("value");
const normalized = normalizePointsConfig(existing?.value);
return res.status(200).json(normalized);
} catch (err) {
return res.status(200).json(DEFAULT_POINTS_CONFIG);
}
});
// Admin-only: set points configuration
router.put("/points-config", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const normalized = normalizePointsConfig(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: POINTS_CONFIG_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizePointsConfig(updated?.value));
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Public: get visibility configuration
router.get("/visibility-config", async (req, res) => {
try {
const existing = await Setting.findOne({ key: VISIBILITY_CONFIG_KEY }).select("value");
const normalized = normalizeVisibilityConfig(existing?.value);
return res.status(200).json(normalized);
} catch (err) {
return res.status(200).json(DEFAULT_VISIBILITY_CONFIG);
}
});
// Admin-only: set visibility configuration
router.put("/visibility-config", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const normalized = normalizeVisibilityConfig(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: VISIBILITY_CONFIG_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizeVisibilityConfig(updated?.value));
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
// Language normalization
function normalizeLanguage(raw) {
const lang = String(raw || "").trim().toLowerCase();
return SUPPORTED_LANGUAGES.includes(lang) ? lang : DEFAULT_LANGUAGE;
}
// Public: get current language setting
router.get("/language", async (req, res) => {
try {
const existing = await Setting.findOne({ key: LANGUAGE_KEY }).select("value");
const language = normalizeLanguage(existing?.value);
return res.status(200).json({ language, supported: SUPPORTED_LANGUAGES });
} catch (err) {
return res.status(200).json({ language: DEFAULT_LANGUAGE, supported: SUPPORTED_LANGUAGES });
}
});
// Admin-only: set language
router.put("/language", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const language = normalizeLanguage(req.body?.language);
await Setting.findOneAndUpdate(
{ key: LANGUAGE_KEY },
{ $set: { value: language } },
{ upsert: true, new: true }
);
return res.status(200).json({ language, supported: SUPPORTED_LANGUAGES });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
});
module.exports = router;
+139
View File
@@ -0,0 +1,139 @@
const express = require("express");
const fs = require("fs");
const path = require("path");
const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
const Movie = require("../models/Movie");
const router = express.Router();
const SUBTITLE_FILES_DIR = path.resolve(process.env.SUBTITLE_FILES_DIR || path.join(process.cwd(), "subtitles"));
function parseCookies(header) {
const cookies = {};
const raw = String(header || "");
if (!raw) return cookies;
raw.split(";").forEach((part) => {
const idx = part.indexOf("=");
if (idx <= 0) return;
const k = part.slice(0, idx).trim();
const v = part.slice(idx + 1).trim();
if (!k) return;
cookies[k] = decodeURIComponent(v);
});
return cookies;
}
function getAuthToken(req) {
const cookies = parseCookies(req.headers.cookie);
const cookieToken = cookies.video_auth ? String(cookies.video_auth) : "";
if (cookieToken) return cookieToken;
const authHeader = String(req.headers.authorization || "");
const headerToken = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
if (headerToken) return headerToken;
const qs = typeof req.query?.token === "string" ? req.query.token.trim() : "";
return qs || "";
}
function requireAuth(req, res) {
const token = getAuthToken(req);
if (!token) return { ok: false, error: res.status(401).json({ message: "Missing subtitle auth token" }) };
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return { ok: true, decoded };
} catch (_) {
return { ok: false, error: res.status(401).json({ message: "Invalid or expired subtitle auth token" }) };
}
}
function isSafeRelativeFile(p) {
if (typeof p !== "string") return false;
const s = p.trim();
if (!s) return false;
if (s.includes("\0")) return false;
if (path.isAbsolute(s)) return false;
const norm = path.posix.normalize(s.replaceAll("\\", "/"));
if (norm.startsWith("../") || norm === "..") return false;
return true;
}
async function streamSubtitle(req, res) {
const auth = requireAuth(req, res);
if (!auth.ok) return;
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(400).json({ message: "Invalid movie id" });
}
const movie = await Movie.findById(id).select("subtitles subtitle_file subtitle_lang subtitle_label subtitle_default").lean();
if (!movie) return res.status(404).json({ message: "Movie not found" });
let subtitleFile = "";
const subtitleId = req.params?.subtitleId;
const subs = Array.isArray(movie?.subtitles) ? movie.subtitles : [];
if (subtitleId) {
if (subtitleId === "legacy") {
subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
} else {
const match = subs.find((s) => String(s?._id || "") === String(subtitleId));
subtitleFile = typeof match?.file === "string" ? match.file.trim() : "";
}
} else if (subs.length > 0) {
const preferred = subs.find((s) => s?.default) || subs[0];
subtitleFile = typeof preferred?.file === "string" ? preferred.file.trim() : "";
} else {
subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
}
if (!subtitleFile) return res.status(404).json({ message: "No subtitles configured for this movie" });
if (!isSafeRelativeFile(subtitleFile)) return res.status(400).json({ message: "Invalid subtitle_file path" });
const fullPath = path.resolve(SUBTITLE_FILES_DIR, subtitleFile);
if (!fullPath.startsWith(SUBTITLE_FILES_DIR + path.sep) && fullPath !== SUBTITLE_FILES_DIR) {
return res.status(400).json({ message: "Invalid subtitle_file path" });
}
let stat;
try {
stat = await fs.promises.stat(fullPath);
} catch (_) {
return res.status(404).json({ message: "Subtitle file not found on server" });
}
if (!stat.isFile()) return res.status(404).json({ message: "Subtitle file not found on server" });
res.setHeader("Content-Type", "text/vtt; charset=utf-8");
res.setHeader("Content-Length", String(stat.size));
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
if (req.method === "HEAD") {
return res.status(200).end();
}
return fs.createReadStream(fullPath).pipe(res);
}
router.get("/:id/:subtitleId", (req, res) => {
streamSubtitle(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
});
});
router.head("/:id/:subtitleId", (req, res) => {
streamSubtitle(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
});
});
router.get("/:id", (req, res) => {
streamSubtitle(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
});
});
router.head("/:id", (req, res) => {
streamSubtitle(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
});
});
module.exports = router;
+672 -39
View File
@@ -1,19 +1,34 @@
const express = require("express"); const express = require("express");
const bcrypt = require("bcryptjs"); const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken"); const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const User = require("../models/User"); const User = require("../models/User");
const Movie = require("../models/Movie"); const Movie = require("../models/Movie");
const Setting = require("../models/Setting");
const PlaybackProgress = require("../models/PlaybackProgress");
const OscarPick = require("../models/OscarPick");
const OscarCategory = require("../models/OscarCategory");
const axios = require("axios"); const axios = require("axios");
const router = express.Router(); const router = express.Router();
// Set password for verification // Set password for verification
const woof = process.env.DOG_NAMES ? process.env.DOG_NAMES.split(",") : []; const woof =
typeof process.env.DOG_NAMES === "string"
? process.env.DOG_NAMES.split(",").map((s) => s.trim()).filter(Boolean)
: [];
const ACTIVE_YEAR_KEY = "active_oscar_year";
// Fetch movie details from OMDb API // Fetch movie details from OMDb API
async function fetchMovieDetailsFromOmdb(imdb_id) { async function fetchMovieDetailsFromOmdb(imdb_id) {
const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key const apiKey = typeof process.env.OMDB_API === "string" ? process.env.OMDB_API.trim() : "";
if (!apiKey) {
const err = new Error("OMDb n'est pas configuré (env OMDB_API manquante).");
err.code = "OMDB_NOT_CONFIGURED";
throw err;
}
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`; const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
try { try {
@@ -49,41 +64,290 @@ function verifyToken(req, res, next) {
}); });
} }
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
function isAdminFromDecoded(decoded) {
return !!decoded?.admin;
}
function signUserToken(user) {
const isAdmin = user.role === 69;
const expiresIn = typeof process.env.JWT_EXPIRES_IN === "string" && process.env.JWT_EXPIRES_IN.trim()
? process.env.JWT_EXPIRES_IN.trim()
: "8h";
return jwt.sign(
{
id: user._id,
name: user.name,
admin: isAdmin,
mustChangePassword: !!user.mustChangePassword,
},
process.env.JWT_SECRET,
{ expiresIn }
);
}
function normalizeThemePreference(value) {
if (value === null || value === undefined) return null;
const raw = String(value || "").trim().toLowerCase();
if (raw === "light" || raw === "dark") return raw;
return null;
}
function generateTempPassword() {
// 12 chars, URL-safe (no spaces/specials that break copy/paste)
return crypto.randomBytes(9).toString("base64url");
}
const TEMP_PASSWORD_TTL_MS = 24 * 60 * 60 * 1000; // 24h
async function getOrInitActiveYear() {
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
if (existing && typeof existing.value === "number") return existing.value;
// Initialize from latest movie year if available, else from current year.
const latestMovie = await Movie.findOne({ year: { $type: "number" } })
.sort({ year: -1 })
.select("year");
const fallbackYear = latestMovie?.year || new Date().getFullYear();
const year = parseOscarYear(fallbackYear) || new Date().getFullYear();
await Setting.findOneAndUpdate(
{ key: ACTIVE_YEAR_KEY },
{ $set: { value: year } },
{ upsert: true, new: true }
);
return year;
}
// ============================================================================
// FIRST-TIME SETUP ENDPOINTS
// These allow creating the first admin user when the database is empty/new
// ============================================================================
// Check if the app needs initial setup (no admin users exist)
router.get("/setup/status", async (req, res) => {
try {
const totalUsers = await User.countDocuments({});
const adminCount = await User.countDocuments({ role: 69 });
res.set("Cache-Control", "no-store");
return res.status(200).json({
needsSetup: adminCount === 0,
hasUsers: totalUsers > 0,
hasAdmin: adminCount > 0,
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Create the first admin user (only works if no admin exists)
router.post("/setup/admin", async (req, res) => {
try {
// Check if an admin already exists
const adminCount = await User.countDocuments({ role: 69 });
if (adminCount > 0) {
return res.status(403).json({
message: "Un administrateur existe déjà. Cette fonctionnalité est désactivée.",
});
}
const { name, email, password } = req.body;
// Validate input
const nameNorm = typeof name === "string" ? name.trim() : "";
const emailNorm = typeof email === "string" ? email.trim().toLowerCase() : "";
if (!nameNorm) {
return res.status(400).json({ message: "Le nom est obligatoire" });
}
if (!emailNorm) {
return res.status(400).json({ message: "L'email est obligatoire" });
}
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
return res.status(400).json({ message: "Email invalide" });
}
if (!password || password.length < 6) {
return res.status(400).json({ message: "Le mot de passe doit contenir au moins 6 caractères" });
}
// Check if email is already taken
const existing = await User.findOne({ email: emailNorm });
if (existing) {
return res.status(409).json({ message: "Cet email est déjà utilisé" });
}
// Create the admin user
const hashedPassword = await bcrypt.hash(password, 10);
const adminUser = new User({
name: nameNorm,
email: emailNorm,
password: hashedPassword,
role: 69, // Admin role
mustChangePassword: false,
});
await adminUser.save();
console.log(`[setup] First admin user created: ${emailNorm}`);
// Return a token so the admin is logged in immediately
const token = signUserToken(adminUser);
return res.status(201).json({
message: "Administrateur créé avec succès",
token,
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Get user stats with watched movie titles and details from the movies collection // Get user stats with watched movie titles and details from the movies collection
router.get("/stats", verifyToken, async (req, res) => { router.get("/stats", verifyToken, async (req, res) => {
try { try {
// Fetch all users // Auth-scoped response: never allow caching.
const users = await User.find(); res.set("Cache-Control", "private, no-store, must-revalidate");
res.set("Vary", "Authorization");
// Fetch all movies from the database // Fetch all users (only needed fields)
const allMovies = await Movie.find(); const users = await User.find().select("name watchedMovies").lean();
// If a year is explicitly provided, use it; otherwise default to the global active year.
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
// Fetch movies (optionally filtered by year)
const allMovies = await Movie.find({ year })
.select("imdb_id title poster rating category year vod_link player_mode video_src embed_src video_file")
.lean();
const totalMoviesCount = allMovies.length; const totalMoviesCount = allMovies.length;
if (totalMoviesCount === 0) { if (totalMoviesCount === 0) {
return res.status(400).json({ message: "Aucun films disponible." }); return res.status(400).json({ message: "Aucun films disponible." });
} }
// Get points configuration
const POINTS_CONFIG_KEY = "points_config";
const pointsConfigSetting = await Setting.findOne({ key: POINTS_CONFIG_KEY }).select("value").lean();
const pointsConfig = pointsConfigSetting?.value && typeof pointsConfigSetting.value === "object"
? {
pointsPerMovie: typeof pointsConfigSetting.value.pointsPerMovie === "number" ? pointsConfigSetting.value.pointsPerMovie : 1,
pointsPerCorrectPick: typeof pointsConfigSetting.value.pointsPerCorrectPick === "number" ? pointsConfigSetting.value.pointsPerCorrectPick : 1,
}
: { pointsPerMovie: 1, pointsPerCorrectPick: 1 };
// Get all categories with winners marked for this year
const categories = await OscarCategory.find({ year }).lean();
const categoryWinners = new Map();
categories.forEach(cat => {
const winner = cat.nominees.find(n => n.isWinner);
if (winner) {
categoryWinners.set(cat.categoryNumber, winner.name);
}
});
// Get all picks for this year and calculate correct picks on-the-fly
let picksByUserId = new Map();
try {
const allPicks = await OscarPick.find({ year })
.select("userId picks")
.lean();
allPicks.forEach(pick => {
const userId = String(pick.userId);
let correctCount = 0;
// Calculate correct picks based on current winners
if (Array.isArray(pick.picks)) {
pick.picks.forEach(p => {
const correctWinner = categoryWinners.get(p.categoryNumber);
if (correctWinner && p.selectedNominee === correctWinner) {
correctCount++;
}
});
}
picksByUserId.set(userId, correctCount);
});
} catch (err) {
console.error("Error fetching picks for stats:", err);
// Continue without picks - users will just have 0 pick points
}
// Build stats for each user // Build stats for each user
const userStats = users.map((user) => { const userStats = users.map((user) => {
// Get watched movies with details from the database const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
const watchedMovies = user.watchedMovies
.map((wm) => allMovies.find((m) => m.imdb_id === wm.imdb_id)) // Match movie details
.filter(Boolean); // Remove any `undefined` values (if movie is not found)
const watchedCount = watchedMovies.length; // Join watched movies with movie details for the selected year (keep watchedDate)
const watchedRaw = Array.isArray(user?.watchedMovies) ? user.watchedMovies : [];
const watchedMoviesDetails = watchedRaw
.map((wm) => {
const imdbId = typeof wm?.imdb_id === "string" ? wm.imdb_id : "";
if (!imdbId) return null;
const movie = movieByImdbId.get(imdbId);
if (!movie) return null;
return {
movieId: movie?._id ? String(movie._id) : null,
imdb_id: movie.imdb_id,
title: movie.title || "",
poster: movie.poster || "",
rating: movie.rating || "",
category: movie.category || "",
year: Number.isInteger(movie.year) ? movie.year : year,
vod_link: movie.vod_link || "",
player_mode: movie.player_mode || "auto",
video_src: movie.video_src || "",
embed_src: movie.embed_src || "",
video_file: movie.video_file || "",
watchedDate: wm?.watchedDate ? new Date(wm.watchedDate).toISOString() : null,
};
})
.filter(Boolean);
const watchedCount = watchedMoviesDetails.length;
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1); const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
const watchedMoviesDetails = watchedMovies.map((movie) => ({ // Calculate the last watched date (used for ranking tiebreakers - earlier completion = better)
imdb_id: movie.imdb_id, let lastWatchedAt = null;
title: movie.title watchedMoviesDetails.forEach((wm) => {
})); if (wm.watchedDate) {
const d = new Date(wm.watchedDate);
if (!lastWatchedAt || d > lastWatchedAt) {
lastWatchedAt = d;
}
}
});
// Calculate points
const userId = String(user._id);
const correctPicks = picksByUserId.get(userId) || 0;
const moviePoints = watchedCount * pointsConfig.pointsPerMovie;
const pickPoints = correctPicks * pointsConfig.pointsPerCorrectPick;
const totalPoints = moviePoints + pickPoints;
return { return {
name: user.name, name: user.name,
userId: userId,
year,
watchedCount, watchedCount,
totalMoviesCount, totalMoviesCount,
watchedRatio: `${watchedRatio}%`, watchedRatio: `${watchedRatio}%`,
watchedMovies: watchedMoviesDetails // Include movie details watchedMovies: watchedMoviesDetails, // Include movie details
correctPicks: correctPicks,
moviePoints: moviePoints,
pickPoints: pickPoints,
totalPoints: totalPoints,
pointsConfig: pointsConfig,
lastWatchedAt: lastWatchedAt ? lastWatchedAt.toISOString() : null,
}; };
}); });
@@ -94,38 +358,172 @@ router.get("/stats", verifyToken, async (req, res) => {
} }
}); });
// Get users who have 100% completion per year (across all years in DB)
// Returns: { years: number[], totals: { [year]: number }, completersByYear: { [year]: {name, watchedCount, totalMoviesCount}[] } }
router.get("/completions", verifyToken, async (req, res) => {
try {
// Auth-scoped response: never allow caching.
res.set("Cache-Control", "private, no-store, must-revalidate");
res.set("Vary", "Authorization");
const yearsRaw = await Movie.distinct("year");
const years = (Array.isArray(yearsRaw) ? yearsRaw : [])
.map((y) => Number(y))
.filter((y) => parseOscarYear(y))
.sort((a, b) => b - a);
if (years.length === 0) {
return res.status(200).json({ years: [], totals: {}, completersByYear: {} });
}
// Fetch imdb_id + year for all movies that have a valid year
const movies = await Movie.find({ year: { $in: years } }).select("imdb_id year").lean();
const imdbIdsByYear = new Map();
const totals = {};
movies.forEach((m) => {
const year = Number(m?.year);
if (!parseOscarYear(year)) return;
const imdb = typeof m?.imdb_id === "string" ? m.imdb_id : "";
if (!imdb) return;
if (!imdbIdsByYear.has(year)) imdbIdsByYear.set(year, new Set());
imdbIdsByYear.get(year).add(imdb);
});
for (const y of years) {
const set = imdbIdsByYear.get(y);
totals[String(y)] = set ? set.size : 0;
}
const users = await User.find().select("name watchedMovies.imdb_id watchedMovies.watchedDate").lean();
const completersByYear = {};
for (const y of years) {
const total = totals[String(y)] || 0;
if (total <= 0) {
completersByYear[String(y)] = [];
continue;
}
const yearSet = imdbIdsByYear.get(y) || new Set();
const completers = [];
users.forEach((u) => {
const watched = Array.isArray(u?.watchedMovies) ? u.watchedMovies : [];
// Build a map of imdb_id -> watchedDate for this user's movies in this year
const watchedInYear = [];
watched.forEach((wm) => {
const imdb = typeof wm?.imdb_id === "string" ? wm.imdb_id : "";
if (!imdb || !yearSet.has(imdb)) return;
watchedInYear.push({
imdb_id: imdb,
watchedDate: wm?.watchedDate ? new Date(wm.watchedDate) : null,
});
});
// Count unique movies watched in this year
const uniqueImdbIds = new Set(watchedInYear.map((w) => w.imdb_id));
const count = uniqueImdbIds.size;
if (count === total) {
// Find the completion date (the latest watchedDate among movies for this year)
// This is when the user completed 100% of the checklist
let completedAt = null;
watchedInYear.forEach((w) => {
if (w.watchedDate && (!completedAt || w.watchedDate > completedAt)) {
completedAt = w.watchedDate;
}
});
completers.push({
name: u?.name || "(sans nom)",
watchedCount: count,
totalMoviesCount: total,
completedAt: completedAt ? completedAt.toISOString() : null,
});
}
});
// Sort by completion date (earliest first = higher rank), then alphabetically for ties
completers.sort((a, b) => {
// Users with a completion date come before those without
if (a.completedAt && !b.completedAt) return -1;
if (!a.completedAt && b.completedAt) return 1;
// Both have dates: earliest first
if (a.completedAt && b.completedAt) {
const dateA = new Date(a.completedAt).getTime();
const dateB = new Date(b.completedAt).getTime();
if (dateA !== dateB) return dateA - dateB;
}
// Tie-breaker: alphabetical
return String(a.name).localeCompare(String(b.name), "fr", { sensitivity: "base" });
});
completersByYear[String(y)] = completers;
}
return res.status(200).json({ years, totals, completersByYear });
} catch (err) {
console.error("Erreur dans l'extraction des completers:", err);
return res.status(500).json({ error: "Internal server error" });
}
});
// Register User // Register User
router.post("/register", async (req, res) => { router.post("/register", async (req, res) => {
const { name, email, password, role, verifoof } = req.body; const { name, email, password, verifoof } = req.body;
const dogCheckEnabled = woof.length > 0;
function registerVerification() { function registerVerification() {
// If DOG_NAMES isn't configured, skip the "dog name" verification entirely.
if (!dogCheckEnabled) return true;
return woof.includes(verifoof); return woof.includes(verifoof);
} }
try { try {
const nameNorm = typeof name === "string" ? name.trim() : "";
const emailNorm = typeof email === "string" ? email.trim() : "";
if (!nameNorm) {
throw new Error("Le nom est obligatoire");
}
if (!emailNorm) {
throw new Error("L'email est obligatoire");
}
// Basic sanity check (not a full RFC validator)
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
throw new Error("Email invalide.");
}
if (!password) { if (!password) {
throw new Error("Le mot de passe est obligatoire et ne peut être indéfini"); throw new Error("Le mot de passe est obligatoire et ne peut être indéfini");
} }
if (!verifoof) { if (dogCheckEnabled && !verifoof) {
throw new Error("Le nom du chien doit être défini" ); throw new Error("Le nom du chien doit être défini");
} }
if (!registerVerification()) { if (!registerVerification()) {
return res.status(201).json({ message: "Mauvais nom de chien" }); return res.status(400).json({
messageKey: 'auth.dogAnswerIncorrect',
message: 'Incorrect dog name.',
});
} }
const existingUser = await User.findOne({ email }); const existingUser = await User.findOne({ email: emailNorm });
if (existingUser) { if (existingUser) {
return res.status(201).json({ message: "L'email existe déjà, veuillez en choisir un autre." }); return res.status(409).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
} }
// Hash password and create user // Hash password and create user
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
const user = new User({ name, email, password: hashedPassword, role }); // SECURITY: never accept role escalation from a public endpoint.
const user = new User({ name: nameNorm, email: emailNorm, password: hashedPassword, role: 0 });
await user.save(); await user.save();
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" }); res.status(201).json({
messageKey: 'auth.registrationSuccess',
message: 'User registered successfully!',
});
} catch (err) { } catch (err) {
res.status(400).json({ error: err.message }); return res.status(400).json({ message: err?.message || "Erreur interne" });
res.status(403).json({ message: "Erreur interne lmao" });
} }
}); });
@@ -135,29 +533,264 @@ router.post("/login", async (req, res) => {
try { try {
const user = await User.findOne({ email }); const user = await User.findOne({ email });
if (!user) return res.status(404).json({ message: "L'utilisateur n'a pas été trouvé" }); if (!user) {
return res.status(404).json({
messageKey: 'auth.userNotFound',
message: 'User not found',
});
}
// If this account is in "temp password" mode and the temp password has expired, block login.
if (user.mustChangePassword && user.tempPasswordExpiresAt && user.tempPasswordExpiresAt instanceof Date) {
if (Date.now() > user.tempPasswordExpiresAt.getTime()) {
return res.status(401).json({
messageKey: 'auth.tempPasswordExpired',
message: 'Temporary password expired. Ask an admin to reset your password.',
});
}
}
const isMatch = await bcrypt.compare(password, user.password); const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) return res.status(401).json({ message: "Informations d'identification non valides" }); if (!isMatch) {
return res.status(401).json({
messageKey: 'auth.invalidCredentials',
message: 'Invalid credentials',
});
}
console.log("[debug] " + user.name + " logged in") console.log("[debug] " + user.name + " logged in")
// Check if the user role is 69 and set the admin flag const token = signUserToken(user);
const isAdmin = user.role === 69; res.status(200).json({ token, mustChangePassword: !!user.mustChangePassword });
// Create a token with the user ID, name, and admin flag if the user is an admin
const token = jwt.sign(
{ id: user._id, name: user.name, admin: isAdmin }, // Include name in the token payload
process.env.JWT_SECRET,
{ expiresIn: "1h" }
);
res.status(200).json({ token });
} catch (err) { } catch (err) {
res.status(400).json({ error: err.message }); res.status(400).json({ error: err.message });
} }
}); });
// User: get theme preference
router.get("/theme", verifyToken, async (req, res) => {
try {
res.set("Cache-Control", "private, no-store, must-revalidate");
res.set("Vary", "Authorization");
const user = await User.findById(req.user.id).select("themePreference").lean();
if (!user) return res.status(404).json({ message: "User not found" });
return res.status(200).json({ themePreference: user.themePreference || null });
} catch (err) {
return res.status(400).json({ message: err?.message || "Erreur interne" });
}
});
// User: update theme preference (light/dark or null for system)
router.put("/theme", verifyToken, async (req, res) => {
try {
const preference = normalizeThemePreference(req.body?.themePreference);
const provided = req.body?.themePreference;
if (provided !== null && provided !== undefined && preference === null) {
return res.status(400).json({ message: "Préférence invalide (light/dark/null)." });
}
const update = preference ? { $set: { themePreference: preference } } : { $unset: { themePreference: 1 } };
const user = await User.findByIdAndUpdate(req.user.id, update, { new: true, select: "themePreference" }).lean();
if (!user) return res.status(404).json({ message: "User not found" });
return res.status(200).json({ themePreference: user.themePreference || null });
} catch (err) {
return res.status(400).json({ message: err?.message || "Erreur interne" });
}
});
// Admin: reset a user's password to a random temp password and force change on next login.
router.post("/admin/reset-temp-password", verifyToken, async (req, res) => {
if (!isAdminFromDecoded(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { email, userId } = req.body || {};
const emailNorm = typeof email === "string" ? email.trim() : "";
const idNorm = typeof userId === "string" ? userId.trim() : "";
if (!emailNorm && !idNorm) {
return res.status(400).json({ message: "Email ou userId requis." });
}
try {
const user = emailNorm ? await User.findOne({ email: emailNorm }) : await User.findById(idNorm);
if (!user) return res.status(404).json({ message: "User not found" });
const tempPassword = generateTempPassword();
const hashedPassword = await bcrypt.hash(tempPassword, 10);
const expiresAt = new Date(Date.now() + TEMP_PASSWORD_TTL_MS);
user.password = hashedPassword;
user.mustChangePassword = true;
user.tempPasswordIssuedAt = new Date();
user.tempPasswordExpiresAt = expiresAt;
await user.save();
return res.status(200).json({
message: "Temporary password generated. The user must change it on next login.",
tempPassword,
expiresAt: expiresAt.toISOString(),
user: { id: String(user._id), name: user.name, email: user.email },
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Admin: list users (safe fields only)
router.get("/admin/list", verifyToken, async (req, res) => {
if (!isAdminFromDecoded(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
try {
// Admin + auth-scoped data: never allow caching.
res.set("Cache-Control", "private, no-store, must-revalidate");
res.set("Vary", "Authorization");
const users = await User.find()
.select("name email role mustChangePassword")
.sort({ name: 1, email: 1 });
return res.status(200).json(
users.map((u) => ({
id: String(u._id),
name: u.name,
email: u.email,
admin: u.role === 69,
mustChangePassword: !!u.mustChangePassword,
}))
);
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Admin: create a user with a random temp password (forces change on first login).
router.post("/admin/create", verifyToken, async (req, res) => {
if (!isAdminFromDecoded(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { name, email, admin } = req.body || {};
const nameNorm = typeof name === "string" ? name.trim() : "";
const emailNorm = typeof email === "string" ? email.trim() : "";
const makeAdmin = !!admin;
if (!nameNorm) {
return res.status(400).json({ message: "Nom requis." });
}
if (!emailNorm) {
return res.status(400).json({ message: "Email requis." });
}
// Basic sanity check (not a full RFC validator)
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
return res.status(400).json({ message: "Email invalide." });
}
try {
const existingUser = await User.findOne({ email: emailNorm });
if (existingUser) {
return res.status(409).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
}
const tempPassword = generateTempPassword();
const hashedPassword = await bcrypt.hash(tempPassword, 10);
const expiresAt = new Date(Date.now() + TEMP_PASSWORD_TTL_MS);
const user = new User({
name: nameNorm,
email: emailNorm,
password: hashedPassword,
role: makeAdmin ? 69 : 0,
mustChangePassword: true,
tempPasswordIssuedAt: new Date(),
tempPasswordExpiresAt: expiresAt,
});
await user.save();
return res.status(201).json({
message: "User created. Temporary password generated.",
tempPassword,
expiresAt: expiresAt.toISOString(),
user: { id: String(user._id), name: user.name, email: user.email, admin: user.role === 69, mustChangePassword: true },
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Admin: delete a user (and related playback progress)
router.post("/admin/delete", verifyToken, async (req, res) => {
if (!isAdminFromDecoded(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { email, userId } = req.body || {};
const emailNorm = typeof email === "string" ? email.trim() : "";
const idNorm = typeof userId === "string" ? userId.trim() : "";
if (!emailNorm && !idNorm) {
return res.status(400).json({ message: "Email ou userId requis." });
}
try {
const user = emailNorm ? await User.findOne({ email: emailNorm }) : await User.findById(idNorm);
if (!user) return res.status(404).json({ message: "User not found" });
// Safety: prevent deleting yourself from the admin UI.
if (String(user._id) === String(req.user?.id || "")) {
return res.status(400).json({ message: "Impossible de supprimer ton propre compte." });
}
// Safety: prevent deleting admin accounts via the UI endpoint.
if (user.role === 69) {
return res.status(400).json({ message: "Impossible de supprimer un compte admin via cette interface." });
}
await PlaybackProgress.deleteMany({ userId: user._id });
await User.deleteOne({ _id: user._id });
return res.status(200).json({
message: "Utilisateur supprimé.",
user: { id: String(user._id), name: user.name, email: user.email },
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// User: change password (used after temp-password login). Returns a fresh token.
router.post("/change-password", verifyToken, async (req, res) => {
const { newPassword } = req.body || {};
const pw = typeof newPassword === "string" ? newPassword : "";
if (!pw || pw.length < 8) {
return res.status(400).json({ message: "Le nouveau mot de passe doit contenir au moins 8 caractères." });
}
try {
const user = await User.findById(req.user?.id);
if (!user) return res.status(404).json({ message: "User not found" });
const hashedPassword = await bcrypt.hash(pw, 10);
user.password = hashedPassword;
user.mustChangePassword = false;
user.tempPasswordIssuedAt = null;
user.tempPasswordExpiresAt = null;
await user.save();
const token = signUserToken(user);
return res.status(200).json({ message: "Mot de passe mis à jour.", token });
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Protected route to get movies (example) // Protected route to get movies (example)
router.get("/movies", verifyToken, async (req, res) => { router.get("/movies", verifyToken, async (req, res) => {
// Only authorized users will reach this point // Only authorized users will reach this point
+96 -41
View File
@@ -1,62 +1,117 @@
const axios = require("axios");
const express = require("express"); const express = require("express");
const moment = require("moment-timezone"); const moment = require("moment-timezone");
const Setting = require("../models/Setting");
const router = express.Router(); const router = express.Router();
let latestCommitCache = null; const TIMEZONE = "America/New_York";
let lastFetchedTime = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
async function fetchLatestCommitFromGitHub() { // Stored in Mongo via Setting model (admin-controlled).
const githubOwner = process.env.GITHUB_OWNER; // Shape:
const githubRepo = process.env.GITHUB_REPO; // {
// activeId: string|null,
// versions: [{ id, version, message, dateISO }]
// }
const APP_VERSION_KEY = "app_version_control";
function nowFormatted() {
return moment(Date.now()).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss");
}
function getPackageVersion() {
try { try {
const url = `https://api.github.com/repos/${githubOwner}/${githubRepo}/commits`; // routes/version.js -> ../package.json
const response = await axios.get(url, { const pkg = require("../package.json");
headers: { return typeof pkg?.version === "string" && pkg.version.trim() ? pkg.version.trim() : "dev";
"Content-Type": "application/json", } catch (_) {
}, return "dev";
});
if (response.data && response.data.length > 0) {
const latestCommit = response.data[0];
latestCommitCache = {
sha: latestCommit.sha,
message: latestCommit.commit.message,
author: latestCommit.commit.author.name,
date: latestCommit.commit.author.date,
};
lastFetchedTime = Date.now();
} else {
throw new Error("No commits found.");
}
} catch (error) {
console.error("Error fetching commit:", error.message);
} }
} }
// Initial fetch to populate cache function normalizeAppVersionControl(rawValue) {
fetchLatestCommitFromGitHub(); const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const versions = Array.isArray(v.versions) ? v.versions : [];
// Refresh cache every 5 minutes const cleaned = versions
setInterval(fetchLatestCommitFromGitHub, CACHE_DURATION); .map((e) => (e && typeof e === "object" && !Array.isArray(e) ? e : {}))
.map((e) => {
const id = typeof e.id === "string" ? e.id.trim() : "";
const version = typeof e.version === "string" ? e.version.trim() : "";
const message = typeof e.message === "string" ? e.message.slice(0, 500) : "";
const dateISO = typeof e.dateISO === "string" ? e.dateISO.trim() : "";
const d = new Date(dateISO);
const safeDateISO = dateISO && !Number.isNaN(d.getTime()) ? d.toISOString() : null;
return {
id: id || null,
version: version || null,
message: String(message || ""),
dateISO: safeDateISO,
};
})
.filter((e) => e.id && e.version)
.sort((a, b) => {
const ad = a.dateISO ? new Date(a.dateISO).getTime() : 0;
const bd = b.dateISO ? new Date(b.dateISO).getTime() : 0;
return bd - ad;
});
const activeId = typeof v.activeId === "string" ? v.activeId.trim() : "";
const active = cleaned.find((e) => String(e.id) === String(activeId)) || null;
// If activeId is invalid, fall back to newest entry.
return {
activeId: active ? String(active.id) : cleaned[0]?.id ? String(cleaned[0].id) : null,
versions: cleaned,
};
}
// Route to get the latest commit (cached) // Route to get the latest commit (cached)
router.get("/", async (req, res) => { router.get("/", async (req, res) => {
if (!latestCommitCache) { // Always return 200 and never rely on GitHub.
return res.status(500).json({ message: "Commit data is not available yet." }); try {
const setting = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(setting?.value);
const active = control.activeId
? control.versions.find((v) => String(v.id) === String(control.activeId)) || null
: null;
if (!active) {
return res.status(200).json({
version: getPackageVersion(),
messageKey: 'footer.versionNotConfigured',
message: 'Version not configured (set it in Admin → Version).',
author: "",
date: nowFormatted(),
configured: false,
source: "fallback",
});
} }
res.json({ const date = active.dateISO
version: latestCommitCache.sha.substring(0, 5), ? moment(active.dateISO).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss")
message: latestCommitCache.message, : nowFormatted();
author: latestCommitCache.author,
date: moment(latestCommitCache.date) return res.status(200).json({
.tz("America/New_York") version: active.version,
.format("YYYY-MM-DD HH:mm:ss"), message: active.message || "",
author: "",
date,
configured: true,
source: "admin",
}); });
} catch (_) {
// If DB is down, still return something useful.
return res.status(200).json({
version: getPackageVersion(),
messageKey: 'footer.versionUnavailable',
message: 'Version unavailable (DB).',
author: "",
date: nowFormatted(),
configured: false,
source: "fallback",
});
}
}); });
module.exports = router; module.exports = router;
+215
View File
@@ -0,0 +1,215 @@
const express = require("express");
const fs = require("fs");
const path = require("path");
const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
const Movie = require("../models/Movie");
const router = express.Router();
function parseCookies(header) {
const cookies = {};
const raw = String(header || "");
if (!raw) return cookies;
raw.split(";").forEach((part) => {
const idx = part.indexOf("=");
if (idx <= 0) return;
const k = part.slice(0, idx).trim();
const v = part.slice(idx + 1).trim();
if (!k) return;
cookies[k] = decodeURIComponent(v);
});
return cookies;
}
function isSafeRelativeFile(p) {
if (typeof p !== "string") return false;
const s = p.trim();
if (!s) return false;
if (s.includes("\0")) return false;
// No absolute paths
if (path.isAbsolute(s)) return false;
// Normalize and prevent traversal
const norm = path.posix.normalize(s.replaceAll("\\", "/"));
if (norm.startsWith("../") || norm === "..") return false;
return true;
}
function guessContentType(filePath) {
const ext = String(path.extname(filePath) || "").toLowerCase();
if (ext === ".mp4") return "video/mp4";
if (ext === ".webm") return "video/webm";
if (ext === ".ogv" || ext === ".ogg") return "video/ogg";
if (ext === ".m4v") return "video/x-m4v";
if (ext === ".mov") return "video/quicktime";
if (ext === ".m3u8") return "application/vnd.apple.mpegurl";
if (ext === ".ts") return "video/mp2t";
return "application/octet-stream";
}
function getVideoBaseDir() {
// Default to your existing public/video folder.
const fromEnv = typeof process.env.VIDEO_FILES_DIR === "string" ? process.env.VIDEO_FILES_DIR.trim() : "";
const base = fromEnv || path.join(process.cwd(), "public", "video");
return path.resolve(base);
}
function getAuthToken(req) {
// <video> can't send Authorization headers, so we use a cookie.
const cookies = parseCookies(req.headers.cookie);
const cookieToken = cookies.video_auth ? String(cookies.video_auth) : "";
if (cookieToken) return cookieToken;
// Optional fallback for non-browser clients:
const authHeader = String(req.headers.authorization || "");
const headerToken = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
if (headerToken) return headerToken;
// Last resort (not recommended, but handy for debugging):
const qs = typeof req.query?.token === "string" ? req.query.token.trim() : "";
return qs || "";
}
function requireAuth(req, res) {
const token = getAuthToken(req);
if (!token) return { ok: false, error: res.status(401).json({ message: "Missing video auth token" }) };
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return { ok: true, decoded };
} catch (_) {
return { ok: false, error: res.status(401).json({ message: "Invalid or expired video auth token" }) };
}
}
// Called by player.html via fetch() (with Authorization header) to set a cookie usable by <video>.
router.post("/session", (req, res) => {
const authHeader = String(req.headers.authorization || "");
const token = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
if (!token) return res.status(401).json({ message: "Token is required" });
try {
jwt.verify(token, process.env.JWT_SECRET);
// Cookie scoped to /api/video so it doesn't leak broadly.
// httpOnly helps a bit, even though the JWT originally lives in localStorage.
const maxAgeSecondsRaw = typeof process.env.VIDEO_SESSION_MAX_AGE_SECONDS === "string" ? process.env.VIDEO_SESSION_MAX_AGE_SECONDS.trim() : "";
const maxAgeSeconds = Number.isFinite(Number(maxAgeSecondsRaw)) && Number(maxAgeSecondsRaw) > 0
? Math.floor(Number(maxAgeSecondsRaw))
: 60 * 60 * 24; // 24h (longer default to avoid expiration during long movie sessions)
const cookie = [
`video_auth=${encodeURIComponent(token)}`,
"Path=/api/video",
`Max-Age=${maxAgeSeconds}`,
"SameSite=Lax",
// If behind HTTPS (recommended), enable Secure.
...(req.secure || req.headers["x-forwarded-proto"] === "https" ? ["Secure"] : []),
"HttpOnly",
].join("; ");
res.setHeader("Set-Cookie", cookie);
return res.status(204).end();
} catch (_) {
return res.status(401).json({ message: "Invalid or expired token" });
}
});
async function streamMovieFile(req, res) {
const auth = requireAuth(req, res);
if (!auth.ok) return;
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(400).json({ message: "Invalid movie id" });
}
const qualityRaw = typeof req.query?.quality === "string" ? req.query.quality.trim().toLowerCase() : "";
const wantsLow = qualityRaw === "low";
const movie = await Movie.findById(id).select("video_file video_file_low").lean();
const videoFile = typeof movie?.video_file === "string" ? movie.video_file.trim() : "";
const videoFileLow = typeof movie?.video_file_low === "string" ? movie.video_file_low.trim() : "";
const chosen = wantsLow ? videoFileLow : videoFile;
if (!chosen) {
return res.status(404).json({ message: wantsLow ? "No low-quality server video file configured for this movie" : "No server video file configured for this movie" });
}
if (!isSafeRelativeFile(chosen)) {
return res.status(400).json({ message: "Invalid video_file path" });
}
const baseDir = getVideoBaseDir();
const fullPath = path.resolve(baseDir, chosen);
if (!fullPath.startsWith(baseDir + path.sep) && fullPath !== baseDir) {
return res.status(400).json({ message: "Invalid video_file path" });
}
let stat;
try {
stat = await fs.promises.stat(fullPath);
} catch (_) {
return res.status(404).json({ message: "Video file not found on server" });
}
if (!stat.isFile()) return res.status(404).json({ message: "Video file not found on server" });
const fileSize = stat.size;
const range = String(req.headers.range || "");
const contentType = guessContentType(fullPath);
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
// HEAD: only headers (no body)
if (req.method === "HEAD") {
res.setHeader("Content-Length", String(fileSize));
return res.status(200).end();
}
if (!range) {
res.setHeader("Content-Length", String(fileSize));
return fs.createReadStream(fullPath).pipe(res);
}
// Example: "bytes=0-1023"
const match = /^bytes=(\d*)-(\d*)$/i.exec(range);
if (!match) {
res.setHeader("Content-Range", `bytes */${fileSize}`);
return res.status(416).end();
}
const startRaw = match[1];
const endRaw = match[2];
let start = startRaw ? Number(startRaw) : 0;
let end = endRaw ? Number(endRaw) : fileSize - 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < 0 || start > end) {
res.setHeader("Content-Range", `bytes */${fileSize}`);
return res.status(416).end();
}
if (start >= fileSize) {
res.setHeader("Content-Range", `bytes */${fileSize}`);
return res.status(416).end();
}
end = Math.min(end, fileSize - 1);
const chunkSize = end - start + 1;
res.status(206);
res.setHeader("Content-Range", `bytes ${start}-${end}/${fileSize}`);
res.setHeader("Content-Length", String(chunkSize));
return fs.createReadStream(fullPath, { start, end }).pipe(res);
}
router.get("/:id", (req, res) => {
streamMovieFile(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Video streaming error" });
});
});
router.head("/:id", (req, res) => {
streamMovieFile(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Video streaming error" });
});
});
module.exports = router;