diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..a09493b --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,34 @@ +name: Deploy Frontend + +on: + push: + tags: + - 'v*.*.*' + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Extract version tag + run: echo "VERSION_TAG=${GITHUB_REF_NAME}" >> $GITHUB_ENV + + - name: Build Docker image + run: | + docker build \ + --build-arg REACT_APP_API_URL="" \ + -t poster-picker-frontend:latest \ + -t poster-picker-frontend:${{ env.VERSION_TAG }} . + + - name: Stop old container + run: | + cd ~/dev-server/poster-picker + docker compose stop frontend || true + + - name: Start new container + run: | + cd ~/dev-server/poster-picker + docker compose up -d --no-build frontend diff --git a/.gitignore b/.gitignore index 8070e75..c56f793 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +*storybook.log +storybook-static diff --git a/Dockerfile b/Dockerfile index 5bcac21..25c61e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app COPY package*.json ./ -ENV NODE_OPTIONS="--max-old-space-size=512" +ENV NODE_OPTIONS="--max-old-space-size=2048" RUN npm ci --silent --legacy-peer-deps COPY . . @@ -22,7 +22,7 @@ FROM nginx:stable-alpine AS runner # Copy build output COPY --from=builder /app/build /usr/share/nginx/html -COPY nginx-spa.conf /etc/nginx/conf.d/nginx-spa.conf +COPY nginx-spa.conf /etc/nginx/conf.d/default.conf # Expose port 80 (Caddy fera reverse proxy) EXPOSE 80 diff --git a/README.md b/README.md index 4de716c..281acd7 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,18 @@ -# Movie Poster Gallery +# Letterboxd Diary Poster Picker -![Project Logo](/assets/project.jpg) +![Project Screenshot](/assets/project.png) -A web application that allows users to browse and select their favorite movie posters based on their Letterboxd diary. +A web application to browse and select your favorite movie posters from your Letterboxd diary. ## Features -- **Letterboxd Integration**: Import your movie diary directly from uploading a CSV file. -- **Poster Gallery**: Browse through posters of movies you've watched. -- **User-friendly Interface**: Intuitive design for easy navigation and selection. -- **Responsive Design**: Works seamlessly on desktop and mobile devices. +- **CSV Import**: Upload your Letterboxd diary export to load your full watch history. +- **RSS Sync**: Sync your ~50 most recent diary entries directly via your Letterboxd username — no export needed. +- **Poster Gallery**: Browse all available posters for each movie via TMDB. +- **Poster Selection**: Pick your favorite poster for each film. +- **Selection Recap**: Review all your selections in one place. +- **ZIP Download**: Download all selected posters as a ZIP file. +- **Responsive Design**: Works on desktop and mobile. ## Getting Started @@ -17,6 +20,7 @@ A web application that allows users to browse and select their favorite movie po - Node.js (v14.0.0 or later) - npm (v6.0.0 or later) +- A running instance of the [backend](https://github.com/Hugyouu/Letterboxd-Diary-Posters-Picker-backend) ### Installation @@ -48,18 +52,19 @@ A web application that allows users to browse and select their favorite movie po ## Usage -1. On the homepage, upload your diary CSV file. -2. Once processed, you'll be redirected to the Poster Selector page. -3. Browse through your watched movies and select your favorite posters. -4. (Add any additional steps or features here) +1. **Import your diary** — either upload a `diary.csv` exported from [letterboxd.com/data/export](https://letterboxd.com/data/export/), or enter your Letterboxd username to sync via RSS. +2. **Browse posters** — for each film in your diary, scroll through the available posters fetched from TMDB. +3. **Select a poster** — click to pick your favorite for each movie. +4. **Download** — use the download button in the header to export all selected posters as a ZIP. -## Technologies Used +## Technologies -- React.js -- Material-UI -- Backend private for now +- React / Redux +- Material UI +- Flask (backend) +- TMDB API ## Acknowledgments -- [Letterboxd](https://letterboxd.com/) for the inspiration and data (diary.csv) -- [The Movie Database (TMDb)](https://www.themoviedb.org/) for the movie poster images +- [Letterboxd](https://letterboxd.com/) for the diary data +- [The Movie Database (TMDb)](https://www.themoviedb.org/) for the poster images diff --git a/assets/project.jpg b/assets/project.jpg deleted file mode 100644 index a522d82..0000000 Binary files a/assets/project.jpg and /dev/null differ diff --git a/assets/project.png b/assets/project.png new file mode 100644 index 0000000..0fc443c Binary files /dev/null and b/assets/project.png differ diff --git a/nginx-spa.conf b/nginx-spa.conf index e9879c4..3c29bef 100644 --- a/nginx-spa.conf +++ b/nginx-spa.conf @@ -6,7 +6,7 @@ server { # Proxy API to backend container location /api/ { - proxy_pass http://poster-picker-backend-1:5000/api/; + proxy_pass http://backend:5000/api/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/package-lock.json b/package-lock.json index a7e500a..6f109f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "frontend", - "version": "0.1.0", + "name": "poster-picker", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "frontend", - "version": "0.1.0", + "name": "poster-picker", + "version": "1.0.0", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", @@ -19,6 +19,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.7.6", + "jszip": "^3.10.1", "lucide-react": "^0.453.0", "material-ui-dropzone": "^3.5.0", "query-string": "^9.1.0", @@ -56,57 +57,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", - "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", "dependencies": { - "@babel/highlight": "^7.24.7", - "picocolors": "^1.0.0" + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.25.4", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz", - "integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", - "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.25.0", - "@babel/helper-compilation-targets": "^7.25.2", - "@babel/helper-module-transforms": "^7.25.2", - "@babel/helpers": "^7.25.0", - "@babel/parser": "^7.25.0", - "@babel/template": "^7.25.0", - "@babel/traverse": "^7.25.2", - "@babel/types": "^7.25.2", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -167,15 +156,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz", - "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.6", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" @@ -207,14 +197,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", - "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.2", - "@babel/helper-validator-option": "^7.24.8", - "browserslist": "^4.23.1", + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -303,6 +293,15 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-member-expression-to-functions": { "version": "7.24.8", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.8.tgz", @@ -317,28 +316,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", - "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", - "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-simple-access": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7", - "@babel/traverse": "^7.25.2" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -429,27 +427,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", - "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", - "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -470,40 +468,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz", - "integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "license": "MIT", "dependencies": { - "@babel/template": "^7.25.0", - "@babel/types": "^7.25.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.6.tgz", - "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.6" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -2139,46 +2122,45 @@ } }, "node_modules/@babel/template": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", - "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.25.0", - "@babel/types": "^7.25.0" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", - "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.25.6", - "@babel/parser": "^7.25.6", - "@babel/template": "^7.25.0", - "@babel/types": "^7.25.6", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.25.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.6.tgz", - "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.24.8", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -2649,16 +2631,19 @@ "license": "MIT" }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "license": "MIT", "dependencies": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } @@ -3603,17 +3588,23 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -3625,15 +3616,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", @@ -3645,15 +3627,15 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -5260,12 +5242,12 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "license": "MIT", "dependencies": { - "@babel/types": "^7.20.7" + "@babel/types": "^7.28.2" } }, "node_modules/@types/body-parser": { @@ -5316,10 +5298,20 @@ "@types/json-schema": "*" } }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/express": { @@ -5772,9 +5764,9 @@ "license": "MIT" }, "node_modules/@types/semver": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "license": "MIT" }, "node_modules/@types/send": { @@ -6103,148 +6095,148 @@ "license": "ISC" }, "node_modules/@webassemblyjs/ast": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", - "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", - "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "node_modules/@webassemblyjs/leb128": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", "dependencies": { "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/utf8": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", - "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-opt": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1", - "@webassemblyjs/wast-printer": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", - "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", - "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", - "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-api-error": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", - "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, @@ -6281,9 +6273,9 @@ } }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -6314,13 +6306,16 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, "peerDependencies": { - "acorn": "^8" + "acorn": "^8.14.0" } }, "node_modules/acorn-jsx": { @@ -7175,6 +7170,18 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/batch": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", @@ -7329,9 +7336,9 @@ "license": "BSD-2-Clause" }, "node_modules/browserslist": { - "version": "4.23.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", - "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -7348,10 +7355,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001646", - "electron-to-chromium": "^1.5.4", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -7468,9 +7476,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001655", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz", - "integrity": "sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==", + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", "funding": [ { "type": "opencollective", @@ -8382,12 +8390,12 @@ } }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -8826,9 +8834,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.13", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz", - "integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==", + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", "license": "ISC" }, "node_modules/email-addresses": { @@ -8875,13 +8883,13 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.17.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", - "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -9046,12 +9054,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", - "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", - "license": "MIT" - }, "node_modules/es-object-atoms": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", @@ -11399,6 +11401,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "9.0.21", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", @@ -14375,15 +14383,15 @@ } }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-buffer": { @@ -14577,6 +14585,54 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -14663,6 +14719,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", @@ -14679,12 +14744,16 @@ "license": "MIT" }, "node_modules/loader-runner": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", - "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/loader-utils": { @@ -15051,9 +15120,9 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/multicast-dns": { @@ -15157,9 +15226,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", - "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "license": "MIT" }, "node_modules/normalize-path": { @@ -15519,6 +15588,12 @@ "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -18496,9 +18571,9 @@ } }, "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -18546,12 +18621,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -18686,6 +18755,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -19621,12 +19696,16 @@ } }, "node_modules/tapable": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", - "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/temp-dir": { @@ -19703,16 +19782,15 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.10", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", - "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.20", + "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.26.0" + "schema-utils": "^4.3.0", + "terser": "^5.31.1" }, "engines": { "node": ">= 10.13.0" @@ -19736,15 +19814,50 @@ } } }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { "node": ">= 10.13.0" @@ -19825,15 +19938,6 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "license": "BSD-3-Clause" }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -20218,9 +20322,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", - "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "funding": [ { "type": "opencollective", @@ -20237,8 +20341,8 @@ ], "license": "MIT", "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -20267,12 +20371,12 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", - "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/util-deprecate": { @@ -20381,9 +20485,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", - "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", @@ -20418,34 +20522,35 @@ } }, "node_modules/webpack": { - "version": "5.94.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", - "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "license": "MIT", "dependencies": { - "@types/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.7.1", - "acorn-import-attributes": "^1.9.5", - "browserslist": "^4.21.10", + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" }, "bin": { "webpack": "bin/webpack.js" @@ -20605,14 +20710,48 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", - "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.0.tgz", + "integrity": "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ==", "license": "MIT", "engines": { "node": ">=10.13.0" } }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "license": "MIT" + }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -20635,15 +20774,31 @@ "node": ">=4.0" } }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { "node": ">= 10.13.0" diff --git a/package.json b/package.json index 318be52..c53a71f 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.7.6", + "jszip": "^3.10.1", "lucide-react": "^0.453.0", "material-ui-dropzone": "^3.5.0", "query-string": "^9.1.0", diff --git a/src/App.js b/src/App.js index ce48591..1268e5b 100644 --- a/src/App.js +++ b/src/App.js @@ -1,33 +1,36 @@ -import React from "react"; import "./styles/App.scss"; -import { HashRouter as Router, Route, Routes } from "react-router-dom"; +import { HashRouter as Router, Route, Routes, useLocation } from "react-router-dom"; import { Provider } from "react-redux"; import { PersistGate } from "redux-persist/integration/react"; import { store, persistor } from "./services/store"; -import PosterSelector from "./components/PosterSelector"; -import PosterGallery from "./components/PosterGallery"; -import UploadDiary from "./components/UploadDiary"; -import Cart from "./components/Cart"; +import Header from "./components/Header"; +import PosterSelector from "./pages/PosterSelector"; +import PosterGallery from "./pages/PosterGallery"; +import UploadDiary from "./pages/UploadDiary"; +import SelectionRecap from "./pages/SelectionRecap"; + +function AppContent() { + const location = useLocation(); + return ( +
+ {location.pathname !== "/" &&
} + + } /> + } /> + } /> + } /> + +
+ ); +} function App() { return ( - { - -
- - } /> - } /> - } - /> - } /> - -
-
- } + + +
); diff --git a/src/components/Cart.js b/src/components/Cart.js deleted file mode 100644 index d1829df..0000000 --- a/src/components/Cart.js +++ /dev/null @@ -1,204 +0,0 @@ -import React, { useState } from "react"; -import { useSelector, useDispatch } from "react-redux"; -import { useNavigate } from "react-router-dom"; -import { removeAllPosters, removePoster } from "../services/action"; -import DeleteIcon from "@material-ui/icons/Delete"; -import { - Container, - Box, - Typography, - Button, - Dialog, - DialogTitle, - DialogContent, - DialogContentText, - DialogActions, -} from "@material-ui/core"; - -const apiUrl = process.env.REACT_APP_API_URL; - -const Cart = () => { - const dispatch = useDispatch(); - const navigate = useNavigate(); - const selectedPosters = useSelector((state) => { - const selections = state.posterSelections; - return Object.entries(selections).flatMap(([movieId, posters]) => - posters.map((poster) => ({ - movieId, - posterId: typeof poster === "string" ? poster : poster.posterId, - watchedDate: poster.watchedDate, - })) - ); - }); - - const [downloadFormat, setDownloadFormat] = useState("zip"); - - // Nouvel état pour la confirmation de suppression globale - const [openConfirmClear, setOpenConfirmClear] = useState(false); - - const handleRemovePoster = (movieId, posterId) => { - if (movieId && posterId) dispatch(removePoster(movieId, posterId)); - }; - - // Ouvre la dialog de confirmation (appelé par le bouton "Clear All Posters") - const handleOpenConfirmClear = () => { - setOpenConfirmClear(true); - }; - - // Ferme la dialog sans supprimer - const handleCloseConfirmClear = () => { - setOpenConfirmClear(false); - }; - - // Confirme et supprime tous les posters - const handleConfirmClearAllPosters = () => { - dispatch(removeAllPosters()); - setOpenConfirmClear(false); - }; - - const handleDownload = async () => { - try { - const response = await fetch(`${apiUrl}/api/download-posters`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - posters: selectedPosters, - format: downloadFormat, - }), - }); - - if (!response.ok) throw new Error("Download failed"); - - const contentType = response.headers.get("content-type"); - if (contentType && contentType.includes("application/zip")) { - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "posters.zip"; - document.body.appendChild(link); - link.click(); - link.remove(); - window.URL.revokeObjectURL(url); - } else { - throw new Error("Invalid response format"); - } - } catch (error) { - console.error("Failed to download posters:", error); - } - }; - - return ( - <> - -
-
- {selectedPosters.length === 0 ? ( - - - Your poster collection is empty. Start exploring movies to add - some posters! - - - - ) : ( - <> - {selectedPosters.map((poster) => ( -
- Movie poster -
- -
-
- ))} -
navigate("/")}> - + -
- - )} -
- -
-

Download Options

- - - -
- - - Confirm deletion - - - Are you sure you want to delete all the posters in your collection? - This action cannot be undone. - - - - - - - -
-
- - ); -}; - -export default Cart; diff --git a/src/components/DownloadButton.js b/src/components/DownloadButton.js deleted file mode 100644 index c95cd2a..0000000 --- a/src/components/DownloadButton.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from "react"; -import { useSelector } from "react-redux"; -import { Fab, makeStyles } from "@material-ui/core"; -import GetAppIcon from "@material-ui/icons/GetApp"; - -const useStyles = makeStyles((theme) => ({ - downloadFab: { - backgroundColor: "#1caff2", - position: "fixed", - bottom: theme.spacing(4), - left: "50%", - transform: "translateX(-50%)", - zIndex: 1000, - }, -})); - -const GlobalDownloadButton = () => { - const classes = useStyles(); - const selectedPosters = useSelector((state) => { - const selections = state.posterSelections; - return Object.values(selections).flat(); - }); - - const totalSelected = selectedPosters.length; - - if (totalSelected === 0) return null; - - const handleDownload = () => { - // Implement your download logic here - console.log("Downloading", totalSelected, "posters"); - }; - - return ( - - - Download ({totalSelected}) - - ); -}; - -export default GlobalDownloadButton; diff --git a/src/components/Header.js b/src/components/Header.js new file mode 100644 index 0000000..b0f7de9 --- /dev/null +++ b/src/components/Header.js @@ -0,0 +1,252 @@ +import React, { useState } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { useNavigate, useMatch } from "react-router-dom"; +import { createSelector } from "reselect"; +import { AppBar, Toolbar, Typography, Button, IconButton, LinearProgress, Box, Tooltip, Link, Menu, MenuItem } from "@material-ui/core"; +import { Snackbar, Alert } from "@mui/material"; +import GetAppIcon from "@material-ui/icons/GetApp"; +import ExitToAppIcon from "@material-ui/icons/ExitToApp"; +import ViewModuleIcon from "@material-ui/icons/ViewModule"; +import SyncIcon from "@material-ui/icons/Sync"; +import MoreVertIcon from "@material-ui/icons/MoreVert"; +import JSZip from "jszip"; +import api from "../services/api"; +import { removeAllPosters } from "../services/action"; + +const selectSelectedPosters = createSelector( + (state) => state.posterSelections, + (posterSelections) => + Object.entries(posterSelections).flatMap(([movieId, posters]) => + (posters || []).map((p) => ({ movieId, posterId: p.posterId, watchedDate: p.watchedDate })) + ) +); + +const Header = () => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const galleryMatch = useMatch("/posters/:movieName/:movieYear"); + const movieName = galleryMatch ? decodeURIComponent(galleryMatch.params.movieName) : null; + + const [downloadState, setDownloadState] = useState(null); // null | { current, total } + const [syncState, setSyncState] = useState(null); // null | 'syncing' | { added: number } + const [menuAnchor, setMenuAnchor] = useState(null); + const [snackbar, setSnackbar] = useState(null); // null | { message, severity } + + const selectedPosters = useSelector(selectSelectedPosters); + const username = useSelector((state) => state.username); + + const count = selectedPosters.length; + + const handleDownload = async () => { + if (downloadState) return; + const total = selectedPosters.length; + setDownloadState({ current: 0, total }); + + try { + const zip = new JSZip(); + + for (let i = 0; i < selectedPosters.length; i++) { + const { movieId, posterId, watchedDate } = selectedPosters[i]; + const url = `https://image.tmdb.org/t/p/original${posterId}`; + const res = await fetch(url); + const blob = await res.blob(); + zip.file(`${watchedDate}_${movieId}.jpg`, blob); + setDownloadState({ current: i + 1, total }); + } + + const content = await zip.generateAsync({ type: "blob" }); + const objectUrl = URL.createObjectURL(content); + const link = document.createElement("a"); + link.href = objectUrl; + link.download = "posters.zip"; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(objectUrl); + } catch (err) { + console.error("Download error:", err); + } finally { + setDownloadState(null); + } + }; + + const handleRssSync = async () => { + if (!username || syncState === 'syncing') return; + setSyncState('syncing'); + try { + const res = await api.post("/api/sync-rss", { username }); + setSyncState({ added: res.data.added }); + setTimeout(() => setSyncState(null), 3000); + if (res.data.added > 0) { + setSnackbar({ message: `${res.data.added} new film${res.data.added > 1 ? 's' : ''} added to your diary`, severity: 'success' }); + window.dispatchEvent(new Event('diary-synced')); + } else { + setSnackbar({ message: 'Already up to date', severity: 'info' }); + } + } catch { + setSyncState(null); + setSnackbar({ message: 'Sync failed. Please try again.', severity: 'error' }); + } + }; + + const handleReset = async () => { + try { + await api.delete("/api/delete-csv"); + dispatch(removeAllPosters()); + navigate("/"); + } catch (err) { + console.error("Reset error:", err); + } + }; + + const isDownloading = !!downloadState; + const progress = isDownloading + ? Math.round((downloadState.current / downloadState.total) * 100) + : 0; + + return ( + <> + setSnackbar(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + setSnackbar(null)} severity={snackbar?.severity} sx={{ width: '100%' }}> + {snackbar?.message} + + + + +
navigate("/PosterSelector")}> + logo + + Poster Picker + +
+ +
+ {movieName ? ( + + {movieName} + + ) : username ? ( + (e.currentTarget.style.opacity = 1)} + onMouseLeave={(e) => (e.currentTarget.style.opacity = 0.7)} + > + {username} + + ) : null} +
+ {/* Desktop actions */} +
+ {username && ( + 0 ? `+${syncState.added} new film${syncState.added > 1 ? 's' : ''}` + : `Sync from ${username}'s RSS` + }> + + + + + + + )} + + + + + + + + {count > 0 && ( + navigate("/recap")} disabled={isDownloading}> + + + )} + {count > 0 && ( + + )} +
+ + {/* Mobile actions — collapsed into a menu */} + setMenuAnchor(e.currentTarget)}> + + + setMenuAnchor(null)} + PaperProps={{ className: "header-menu-paper" }} + getContentAnchorEl={null} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + {username && ( + { handleRssSync(); setMenuAnchor(null); }} + disabled={isDownloading || syncState === 'syncing'} + className="header-menu-item" + > + + {syncState === 'syncing' ? 'Syncing…' + : syncState?.added === 0 ? 'Up to date' + : syncState?.added > 0 ? `+${syncState.added} new film${syncState.added > 1 ? 's' : ''}` + : 'Sync RSS'} + + )} + {count > 0 && ( + { navigate('/recap'); setMenuAnchor(null); }} className="header-menu-item"> + + My selections ({count}) + + )} + {count > 0 && ( + { handleDownload(); setMenuAnchor(null); }} + disabled={isDownloading} + className="header-menu-item" + > + + {isDownloading + ? `Downloading… ${downloadState.current}/${downloadState.total}` + : `Download (${count})`} + + )} + { handleReset(); setMenuAnchor(null); }} className="header-menu-item header-menu-item--danger"> + + Reset diary + + +
+
+ + ); +}; + +export default Header; diff --git a/src/components/NavBar.js b/src/components/NavBar.js deleted file mode 100644 index 4c51c06..0000000 --- a/src/components/NavBar.js +++ /dev/null @@ -1,74 +0,0 @@ -import React, { useMemo } from "react"; -import axios from "axios"; -import { useSelector } from "react-redux"; -import { AppBar, Toolbar, IconButton, Badge, Button } from "@material-ui/core"; -import ShoppingCartIcon from "@material-ui/icons/ShoppingCart"; -import ArrowBackIcon from "@material-ui/icons/ArrowBack"; -import { useNavigate, useLocation } from "react-router-dom"; - -const apiUrl = process.env.REACT_APP_API_URL; - -const selectPosterSelections = (state) => state.posterSelections; - -const NavBar = () => { - const navigate = useNavigate(); - const location = useLocation(); - - const posterSelections = useSelector(selectPosterSelections); - const selectedPosters = useMemo(() => { - return Object.values(posterSelections).flat(); - }, [posterSelections]); - - const totalSelected = selectedPosters.length; - - const handleBack = () => { - navigate(-1); - }; - - const handleCart = () => { - navigate("/Cart"); - }; - - const handleResetprofile = async () => { - try { - await axios.delete(`${apiUrl}/api/delete-csv`, { withCredentials: true }); - navigate("/"); - } catch (error) { - console.error("Error deleting CSV file:", error); - } - }; - - const isPosterSelectorPage = location.pathname !== "/PosterSelector"; - - return ( - - - {isPosterSelectorPage ? ( - - - - ) : ( - - {/* Disabled IconButton */} - - )} -
- - - - - - -
-
-
- ); -}; - -export default NavBar; diff --git a/src/components/PosterGallery.js b/src/components/PosterGallery.js deleted file mode 100644 index c2f1169..0000000 --- a/src/components/PosterGallery.js +++ /dev/null @@ -1,167 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { useLocation, useParams } from "react-router-dom"; -import axios from "axios"; -import { useSelector, useDispatch } from "react-redux"; -import { selectPoster, deselectPoster } from "../services/action"; -import { - Container, - Typography, - Grid, - Card, - CardMedia, - CircularProgress, - Box, - Paper, - DialogContent, - DialogTitle, - DialogActions, - Button, -} from "@material-ui/core"; -import CheckIcon from "@material-ui/icons/Check"; -import NavBar from "./NavBar"; -import { Dialog } from "@mui/material"; - -const apiUrl = process.env.REACT_APP_API_URL; - -const PosterGallery = () => { - const location = useLocation(); - const { watchedDate } = location.state || {}; - const { movieName, movieYear } = useParams(); - const [posters, setPosters] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [dialogOpen, setDialogOpen] = useState(false); - const dispatch = useDispatch(); - - const movieId = `${movieName}-${movieYear}`; - - const selectedPosters = useSelector( - (state) => state.posterSelections[movieId] || [] - ); - - useEffect(() => { - const fetchPosters = async () => { - try { - setLoading(true); - setError(null); - - const encodedName = encodeURIComponent(movieName); - const encodedYear = encodeURIComponent(movieYear); - - const response = await axios.get( - `${apiUrl}/api/posters/${encodedName}/${encodedYear}` - ); - - if (response.data && response.data.posters) { - setPosters(response.data.posters); - } else { - throw new Error("No posters data in response"); - } - } catch (error) { - console.error("Error fetching posters:", error); - } finally { - setLoading(false); - } - }; - - if (movieName && movieYear) { - fetchPosters(); - } - }, [movieName, movieYear]); - - const isPosterSelected = (posterId) => { - return selectedPosters.some((poster) => poster.posterId === posterId); - }; - - const handlePosterSelect = (posterId) => { - const isSelected = isPosterSelected(posterId); - - if (selectedPosters.length > 0 && !isSelected) { - setDialogOpen(true); - return; - } - if (isSelected) { - dispatch(deselectPoster(movieName, movieYear, posterId)); - } else { - dispatch(selectPoster(movieName, movieYear, posterId, watchedDate)); - } - }; - - const handleDialogClose = () => { - setDialogOpen(false); - }; - - if (error) { - return ( - - - - {error} - - - - - ); - } - - return ( - <> - - - - Posters for {movieName} ({movieYear}) - - {loading ? ( - - - - ) : posters.length > 0 ? ( - - {posters.map((poster, index) => { - const isSelected = isPosterSelected(poster.file_path); - return ( - - handlePosterSelect(poster.file_path)} - > - - {isSelected && } - - - ); - })} - - ) : ( - - No posters found for this movie. - - )} - - - - - Poster already selected - - - You can only select one poster per movie. Please deselect the - current poster first. - - - - - - - - ); -}; - -export default PosterGallery; diff --git a/src/components/PosterSelector.js b/src/components/PosterSelector.js deleted file mode 100644 index c583e04..0000000 --- a/src/components/PosterSelector.js +++ /dev/null @@ -1,256 +0,0 @@ -import React, { useState, useEffect } from "react"; -import axios from "axios"; -import { useNavigate, useLocation } from "react-router-dom"; -import { - Container, - List, - ListItem, - Grid, - Typography, - LinearProgress, - Box, - Paper, - Button, -} from "@material-ui/core"; -import Pagination from "@mui/material/Pagination"; -import { createTheme, ThemeProvider } from "@mui/material/styles"; -import queryString from "query-string"; -import pulpGif from "../static/images/pulp.gif"; -import NavBar from "./NavBar"; - -const apiUrl = process.env.REACT_APP_API_URL; - -const paginationTheme = createTheme({ - palette: { - mode: "dark", - primary: { - main: "#667788", - }, - text: { - primary: "#ffffff", - secondary: "#667788", - }, - action: { - hover: "rgba(102, 119, 136, 0.2)", - }, - }, - components: { - MuiPaginationItem: { - styleOverrides: { - root: { - "&.Mui-selected": { - backgroundColor: "#667788", - color: "#ffffff", - "&:hover": { - backgroundColor: "#778899", - }, - }, - }, - }, - }, - }, -}); - -const PosterSelector = () => { - const [movies, setMovies] = useState([]); - const [loading, setLoading] = useState(true); - const [page, setPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); - //const [progress, setProgress] = useState(0); - const [username, setUsername] = useState(""); - const navigate = useNavigate(); - const location = useLocation(); - const moviesPerPage = 8; - - useEffect(() => { - const parsed = queryString.parse(location.search); - const pageNumber = parsed.page ? parseInt(parsed.page, 10) : 1; - setPage(pageNumber); - - fetchMovies(pageNumber); - }, [location.search]); - - // const fetchUsername = async () => { - // try { - // const response = await axios.get(`${apiUrl}/api/username`); - // setUsername(response.data.username); - // } catch (error) { - // console.error("Error fetching username:", error); - // } - // }; - - const fetchMovies = async (pageNumber) => { - try { - setLoading(true); - // setProgress(0); - - const response = await axios.get( - `${apiUrl}/api/movies?page=${pageNumber}&limit=${moviesPerPage}`, - { withCredentials: true } - ); - - if (response.data.movies) { - setMovies(response.data.movies); - setTotalPages(Math.ceil(response.data.total / moviesPerPage)); - } - } catch (error) { - console.error("Error fetching movies:", error); - } finally { - setLoading(false); - } - }; - - const handleMovieClick = (movieName, movieYear, watchedDate) => { - navigate( - `/posters/${encodeURIComponent(movieName)}/${encodeURIComponent( - movieYear - )}`, - { state: { watchedDate } } - ); - }; - - const handlePageChange = (event, value) => { - setPage(value); - navigate(`?page=${value}`); - }; - - const handleGoBack = () => { - navigate("/"); - }; - - const formatWatchedDate = (dateString) => { - const date = new Date(dateString); - const day = date.getDate().toString().padStart(2, "0"); - const month = date - .toLocaleString("default", { month: "short" }) - .toUpperCase(); - return { day, month }; - }; - - return ( - <> - - - - Your diary - - - {username && ( - - Letterboxd User: {username} - - )} - - {loading ? ( - - - - Downloading movies... - - - ) : movies.length > 0 ? ( - <> - - {movies.map((movie, index) => ( - - handleMovieClick( - movie.Name, - movie.Year, - movie["Watched Date"] - ) - } - className="movie-item" - > - - - {movie.Name} - - - - {movie.Name} - - - {movie.Year} - - - - - {(() => { - const { day, month } = formatWatchedDate( - movie["Watched Date"] - ); - return ( - <> - - {day} - - - {month} - - - ); - })()} - - - - - ))} - - - - - - - - ) : ( - -
-
- Confused reaction -
- - No movies found in your diary. Please check your Letterboxd - username or CSV file. - - -
- )} -
-
- - - ); -}; - -export default PosterSelector; diff --git a/src/components/UploadDiary.js b/src/components/UploadDiary.js deleted file mode 100644 index 3073dc9..0000000 --- a/src/components/UploadDiary.js +++ /dev/null @@ -1,192 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { useNavigate } from "react-router-dom"; -import { - Container, - Grid, - Typography, - Button, - CircularProgress, - TextField, - Box, - Link, -} from "@material-ui/core"; -import { CloudUploadOutlined } from "@material-ui/icons"; -import CloudDoneOutlinedIcon from "@mui/icons-material/CloudDoneOutlined"; -import { useDropzone } from "react-dropzone"; -import axios from "axios"; - -const apiUrl = process.env.REACT_APP_API_URL; -const VERSION = require("../../package.json").version; - -const UploadDiary = () => { - const [file, setFile] = useState(null); - const [uploading, setUploading] = useState(false); - const [username, setUsername] = useState(""); - const navigate = useNavigate(); - - useEffect(() => { - const checkCSVFile = async () => { - try { - const response = await axios.get(`${apiUrl}/api/check-csv`, { - withCredentials: true, - }); - if (response.data.fileExists) { - navigate("/PosterSelector"); - } - } catch (error) { - console.error("Error checking CSV file:", error); - } - }; - checkCSVFile(); - }, [navigate]); - - const { getRootProps, getInputProps } = useDropzone({ - onDrop: (acceptedFiles) => { - setFile(acceptedFiles[0]); - }, - maxFiles: 1, - accept: ".csv", - }); - - const handleUpload = async () => { - if (username || file) { - setUploading(true); - try { - if (username) { - const response = await axios.post( - `${apiUrl}/api/fetch-diary`, - { username }, - { withCredentials: true } - ); - - if (response.data && response.data.success) { - await new Promise((resolve) => setTimeout(resolve, 500)); - navigate("/PosterSelector"); - } else { - console.error("Failed to fetch diary", response.data); - } - } else if (file) { - const formData = new FormData(); - formData.append("file", file); - - await axios.post(`${apiUrl}/api/upload-csv`, formData, { - headers: { "Content-Type": "multipart/form-data" }, - withCredentials: true, - }); - - navigate("/PosterSelector"); - } - } catch (error) { - console.error("Error processing diary:", error); - } finally { - setUploading(false); - } - } - }; - - return ( - - -
-
- logo -
- - Upload Letterboxd Diary - - setUsername(e.target.value)} - onKeyUp={(e) => { - if (e.key === "Enter") handleUpload(); - }} - placeholder="Enter your Letterboxd username" - /> - - Or upload a CSV file: - -
- - {file ? ( - <> - - - Your file has been uploaded: {file.name} - - - ) : ( - <> - - - Drag and drop a CSV file here or click to select - - - )} -
- - {uploading ? ( - - ) : ( - - )} - -
- - This tool allows you to easily import your Letterboxd diary and select - your favorite movie poster. Follow these simple steps: -
- 1. Enter Your Letterboxd Username: Type your - Letterboxd username to automatically fetch your diary. -
- 2. Or Upload Your CSV File: If you prefer, you can - still upload your diary.csv file directly. -
- 3. Automatic Processing: Once submitted, the - application will process your diary. -
- 4. Poster Selection: After processing, you'll be - redirected to the Poster Selector page, where you can browse and - choose your favorite movie poster based on your Letterboxd diary. -
-
- - - - v{VERSION} - - - • - - (e.currentTarget.style.opacity = 1)} - onMouseLeave={(e) => (e.currentTarget.style.opacity = 0.7)} - > - Hugyouu - - - -
- ); -}; - -export default UploadDiary; diff --git a/src/index.js b/src/index.js index d665ff2..4080a97 100644 --- a/src/index.js +++ b/src/index.js @@ -5,11 +5,7 @@ import App from "./App"; import reportWebVitals from "./reportWebVitals"; const root = ReactDOM.createRoot(document.getElementById("root")); -root.render( - - - -); +root.render(); // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) diff --git a/src/pages/PosterGallery.js b/src/pages/PosterGallery.js new file mode 100644 index 0000000..16bac5c --- /dev/null +++ b/src/pages/PosterGallery.js @@ -0,0 +1,150 @@ +import React, { useState, useEffect } from "react"; +import { useLocation, useParams, useNavigate } from "react-router-dom"; +import api from "../services/api"; +import { useSelector, useDispatch } from "react-redux"; +import { selectPoster, deselectPoster } from "../services/action"; +import { + Container, + Typography, + Grid, + Card, + Box, + Paper, +} from "@material-ui/core"; +import { ToggleButton, ToggleButtonGroup } from "@mui/material"; +import CheckIcon from "@material-ui/icons/Check"; + +const LANG_FILTERS = [ + { value: "all", label: "All" }, + { value: "en", label: "EN" }, + { value: "fr", label: "FR" }, + { value: "ja", label: "JA" }, + { value: "none", label: "—" }, +]; + +const PosterCard = ({ poster, movieName, isSelected, onClick }) => { + const [imgLoaded, setImgLoaded] = useState(false); + + return ( + +
+
+ {movieName} setImgLoaded(true)} + /> +
+ {isSelected && } + + ); +}; + +const PosterGallery = () => { + const location = useLocation(); + const { watchedDate } = location.state || {}; + const { movieName, movieYear } = useParams(); + const navigate = useNavigate(); + const [posters, setPosters] = useState([]); + const [loading, setLoading] = useState(true); + const [langFilter, setLangFilter] = useState("all"); + const dispatch = useDispatch(); + + const movieId = `${movieName}-${movieYear}`; + const selectedPosters = useSelector( + (state) => state.posterSelections[movieId] || [] + ); + + useEffect(() => { + const fetchPosters = async () => { + try { + setLoading(true); + const response = await api.get( + `/api/posters/${encodeURIComponent(movieName)}/${encodeURIComponent(movieYear)}` + ); + if (response.data?.posters) setPosters(response.data.posters); + } catch (error) { + console.error("Error fetching posters:", error); + } finally { + setLoading(false); + } + }; + if (movieName && movieYear) fetchPosters(); + }, [movieName, movieYear]); + + const filteredPosters = posters.filter((p) => { + if (langFilter === "all") return true; + if (langFilter === "none") return !p.iso_639_1; + return p.iso_639_1 === langFilter; + }); + + const isPosterSelected = (posterId) => + selectedPosters.some((p) => p.posterId === posterId); + + const handlePosterSelect = (posterId) => { + if (isPosterSelected(posterId)) { + dispatch(deselectPoster(movieName, movieYear, posterId)); + } else { + dispatch(selectPoster(movieName, movieYear, posterId, watchedDate)); + navigate(-1); + } + }; + + const visiblePosters = loading + ? Array.from({ length: 8 }).map((_, i) => ({ _skeleton: true, file_path: `sk-${i}` })) + : filteredPosters; + + return ( + + + + v && setLangFilter(v)} + className="lang-toggle-group" + > + {LANG_FILTERS.map(({ value, label }) => ( + + {label} + + ))} + + + + {!loading && filteredPosters.length === 0 ? ( + + No posters for this language. + + ) : ( + + {visiblePosters.map((poster, index) => + poster._skeleton ? ( + +
+
+
+ + ) : ( + + handlePosterSelect(poster.file_path)} + /> + + ) + )} + + )} + + + ); +}; + +export default PosterGallery; diff --git a/src/pages/PosterSelector.js b/src/pages/PosterSelector.js new file mode 100644 index 0000000..04df6a1 --- /dev/null +++ b/src/pages/PosterSelector.js @@ -0,0 +1,388 @@ +import React, { useState, useEffect, useRef, useCallback } from "react"; +import api from "../services/api"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { useSelector } from "react-redux"; +import { + Container, + Typography, + LinearProgress, + Box, + Paper, + Button, + IconButton, + Select, + MenuItem, + Popover, +} from "@material-ui/core"; +// LinearProgress kept for the initializing state only +import ChevronLeftIcon from "@material-ui/icons/ChevronLeft"; +import ChevronRightIcon from "@material-ui/icons/ChevronRight"; +import CheckIcon from "@material-ui/icons/Check"; +import pulpGif from "../static/images/pulp.gif"; + +const CURRENT_YEAR = new Date().getFullYear(); +const YEARS = Array.from({ length: CURRENT_YEAR - 2000 + 1}, (_, i) => 2000 + i).reverse(); + +const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; + +const MONTH_NAMES = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +const PosterSelector = () => { + const [currentMonth, setCurrentMonth] = useState(null); + const [moviesByDate, setMoviesByDate] = useState({}); + const [loading, setLoading] = useState(true); + const [initializing, setInitializing] = useState(true); + const [popoverAnchor, setPopoverAnchor] = useState(null); + const [popoverMovies, setPopoverMovies] = useState([]); + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const posterSelections = useSelector((state) => state.posterSelections); + const dataSource = useSelector((state) => state.dataSource); + + useEffect(() => { + const init = async () => { + try { + const csvCheck = await api.get("/api/check-csv"); + if (!csvCheck.data.fileExists) { + navigate("/"); + return; + } + + const urlMonth = searchParams.get("month"); + if (urlMonth) { + const [year, month] = urlMonth.split("-").map(Number); + setCurrentMonth({ year, month: month - 1 }); + setInitializing(false); + return; + } + + const res = await api.get("/api/movies?page=1&limit=1"); + if (res.data.movies?.length > 0) { + const latestDate = res.data.movies[0]["Watched Date"]; + const [year, month] = latestDate.split("-").map(Number); + setCurrentMonth({ year, month: month - 1 }); + } else { + const now = new Date(); + setCurrentMonth({ year: now.getFullYear(), month: now.getMonth() }); + } + } catch { + navigate("/"); + } finally { + setInitializing(false); + } + }; + init(); + }, [navigate, searchParams]); + + useEffect(() => { + if (!currentMonth) return; + fetchMoviesForMonth(currentMonth.year, currentMonth.month); + }, [currentMonth]); + + const fetchMoviesForMonth = useCallback(async (year, month) => { + setLoading(true); + try { + const monthStr = `${year}-${String(month + 1).padStart(2, "0")}`; + const res = await api.get(`/api/movies?month=${monthStr}`); + const map = {}; + res.data.movies?.forEach((movie) => { + const date = movie["Watched Date"]; + if (!map[date]) map[date] = []; + map[date].push(movie); + }); + setMoviesByDate(map); + } catch (err) { + console.error("Error fetching movies:", err); + } finally { + setLoading(false); + } + }, []); + + const currentMonthRef = useRef(currentMonth); + useEffect(() => { currentMonthRef.current = currentMonth; }, [currentMonth]); + + useEffect(() => { + const handleSync = () => { + const m = currentMonthRef.current; + if (m) fetchMoviesForMonth(m.year, m.month); + }; + window.addEventListener('diary-synced', handleSync); + return () => window.removeEventListener('diary-synced', handleSync); + }, [fetchMoviesForMonth]); + + const updateMonth = (year, month) => { + setCurrentMonth({ year, month }); + setSearchParams({ month: `${year}-${String(month + 1).padStart(2, "0")}` }); + }; + + const goToPrevMonth = () => { + const { year, month } = currentMonth; + if (month === 0) updateMonth(year - 1, 11); + else updateMonth(year, month - 1); + }; + + const goToNextMonth = () => { + const { year, month } = currentMonth; + if (month === 11) updateMonth(year + 1, 0); + else updateMonth(year, month + 1); + }; + + const goToToday = () => { + const now = new Date(); + updateMonth(now.getFullYear(), now.getMonth()); + }; + + const handleGoToUpload = async () => { + try { + await api.delete("/api/delete-csv"); + } catch { + // server may have restarted, proceed anyway + } + navigate("/"); + }; + + const handleMovieClick = (movie) => { + navigate( + `/posters/${encodeURIComponent(movie.Name)}/${encodeURIComponent(movie.Year)}`, + { state: { watchedDate: movie["Watched Date"] } } + ); + }; + + const handleCellClick = (e, movies) => { + if (!movies.length) return; + if (movies.length === 1) { + handleMovieClick(movies[0]); + } else { + setPopoverMovies(movies); + setPopoverAnchor(e.currentTarget); + } + }; + + const buildCalendarCells = () => { + if (!currentMonth) return []; + const { year, month } = currentMonth; + const firstDayOfWeek = new Date(year, month, 1).getDay(); + const startOffset = (firstDayOfWeek + 6) % 7; // Mon=0 + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + const cells = []; + for (let i = 0; i < startOffset; i++) cells.push(null); + for (let d = 1; d <= daysInMonth; d++) cells.push(d); + return cells; + }; + + if (initializing) { + return ( + <> + + + + + + + + + ); + } + + const cells = buildCalendarCells(); + const hasAnyMovie = Object.keys(moviesByDate).length > 0; + const today = new Date(); + const isCurrentMonth = + currentMonth.year === today.getFullYear() && + currentMonth.month === today.getMonth(); + const isFutureMonth = + currentMonth.year > today.getFullYear() || + (currentMonth.year === today.getFullYear() && currentMonth.month > today.getMonth()); + + return ( + <> + + + + + + + + + + {currentMonth + ? `${MONTH_NAMES[currentMonth.month]} ${currentMonth.year}` + : ""} + + + + + + + + + + + + {dataSource === 'rss' && ( + + + RSS sync — showing your last ~50 films only.{' '} + + Upload a CSV + {' '} + for your full history. + + )} + +
+
+ {DAY_LABELS.map((label) => ( +
{label}
+ ))} + {cells.map((day, i) => { + if (!day) { + return
; + } + + const { year, month } = currentMonth; + const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + const isFuture = isFutureMonth || (isCurrentMonth && day > today.getDate()); + const isToday = isCurrentMonth && day === today.getDate(); + const movies = isFuture ? [] : (moviesByDate[dateStr] || []); + const movie = movies[0]; + + const movieId = movie ? `${movie.Name}-${movie.Year}` : null; + const selection = movieId ? posterSelections[movieId]?.[0] : null; + const posterSrc = selection + ? `https://image.tmdb.org/t/p/w200${selection.posterId}` + : movie?.Poster + ? `https://image.tmdb.org/t/p/w200${movie.Poster}` + : null; + + return ( +
!loading && movies.length > 0 && handleCellClick(e, movies)} + > + {day} + {loading && !isFuture && ( +
+
+
+ )} + {!loading && movie && posterSrc && ( +
+ {movie.Name} +
+ {movie.Name} + {movie.Year} +
+ {selection && ( + + + + )} + {movies.length > 1 && ( + +{movies.length - 1} + )} +
+ )} +
+ ); + })} +
+ + {!loading && !hasAnyMovie && !isFutureMonth && ( + +
+
+ No movies +
+ + No movies watched this month. + + +
+ )} +
+ + + + setPopoverAnchor(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + transformOrigin={{ vertical: "top", horizontal: "center" }} + PaperProps={{ className: "multi-movie-popover" }} + > + {popoverMovies.map((movie) => { + const movieId = `${movie.Name}-${movie.Year}`; + const selection = posterSelections[movieId]?.[0]; + const posterSrc = selection + ? `https://image.tmdb.org/t/p/w200${selection.posterId}` + : movie.Poster + ? `https://image.tmdb.org/t/p/w200${movie.Poster}` + : null; + return ( +
{ handleMovieClick(movie); setPopoverAnchor(null); }} + > + {posterSrc ? ( + {movie.Name} + ) : ( +
+ )} +
+ {movie.Name} + {movie.Year} +
+ {selection && } +
+ ); + })} + + + ); +}; + +export default PosterSelector; diff --git a/src/pages/SelectionRecap.js b/src/pages/SelectionRecap.js new file mode 100644 index 0000000..40e85ad --- /dev/null +++ b/src/pages/SelectionRecap.js @@ -0,0 +1,173 @@ +import React, { useState } from "react"; +import { useSelector } from "react-redux"; +import { useNavigate } from "react-router-dom"; +import { + Container, + Typography, + Box, + Button, + IconButton, + LinearProgress, +} from "@material-ui/core"; +import GetAppIcon from "@material-ui/icons/GetApp"; +import ArrowBackIcon from "@material-ui/icons/ArrowBack"; +import CheckIcon from "@material-ui/icons/Check"; +import JSZip from "jszip"; + +const MONTH_NAMES = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +const SelectionRecap = () => { + const navigate = useNavigate(); + const posterSelections = useSelector((state) => state.posterSelections); + const [downloadState, setDownloadState] = useState(null); + + const entries = Object.entries(posterSelections) + .flatMap(([movieId, posters]) => { + if (!posters?.length) return []; + const match = movieId.match(/^(.+)-(\d{4})$/); + if (!match) return []; + return posters.map((p) => ({ + movieId, + movieName: match[1], + movieYear: match[2], + posterId: p.posterId, + watchedDate: p.watchedDate || "", + })); + }) + .sort((a, b) => a.watchedDate.localeCompare(b.watchedDate)); + + const groups = entries.reduce((acc, entry) => { + const key = entry.watchedDate.slice(0, 7); + if (!acc[key]) acc[key] = []; + acc[key].push(entry); + return acc; + }, {}); + + const sortedMonths = Object.keys(groups).sort().reverse(); + + const handleDownload = async () => { + if (downloadState) return; + setDownloadState({ current: 0, total: entries.length }); + try { + const zip = new JSZip(); + for (let i = 0; i < entries.length; i++) { + const { movieId, posterId, watchedDate } = entries[i]; + const res = await fetch(`https://image.tmdb.org/t/p/original${posterId}`); + const blob = await res.blob(); + zip.file(`${watchedDate}_${movieId}.jpg`, blob); + setDownloadState({ current: i + 1, total: entries.length }); + } + const content = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(content); + const link = document.createElement("a"); + link.href = url; + link.download = "posters.zip"; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (err) { + console.error("Download error:", err); + } finally { + setDownloadState(null); + } + }; + + if (entries.length === 0) { + return ( + + + No posters selected yet. + + + + ); + } + + const isDownloading = !!downloadState; + const progress = isDownloading + ? Math.round((downloadState.current / downloadState.total) * 100) + : 0; + + return ( + + + navigate(-1)}> + + + + Selected Posters ({entries.length}) + + + + + {sortedMonths.map((monthKey) => { + const [year, month] = monthKey.split("-").map(Number); + const monthLabel = `${MONTH_NAMES[month - 1]} ${year}`; + return ( + + {monthLabel} +
+ {groups[monthKey].map((entry) => ( +
+ navigate( + `/posters/${encodeURIComponent(entry.movieName)}/${encodeURIComponent(entry.movieYear)}`, + { state: { watchedDate: entry.watchedDate } } + ) + } + > +
+ {entry.movieName} +
+ {entry.movieName} + {entry.movieYear} +
+ + + +
+
+ ))} +
+
+ ); + })} +
+ ); +}; + +export default SelectionRecap; diff --git a/src/pages/UploadDiary.js b/src/pages/UploadDiary.js new file mode 100644 index 0000000..d5b1bfb --- /dev/null +++ b/src/pages/UploadDiary.js @@ -0,0 +1,236 @@ +import React, { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; +import { + Container, + Grid, + Typography, + CircularProgress, + Box, + Link, + TextField, + InputAdornment, +} from "@material-ui/core"; +import { Button } from "@mui/material"; +import { CloudUploadOutlined } from "@material-ui/icons"; +import CloudDoneOutlinedIcon from "@mui/icons-material/CloudDoneOutlined"; +import SyncIcon from "@material-ui/icons/Sync"; +import { useDropzone } from "react-dropzone"; +import api from "../services/api"; +import { setUsername, setDataSource } from "../services/action"; +const VERSION = require("../../package.json").version; + +const UploadDiary = () => { + const [file, setFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [usernameInput, setUsernameInput] = useState(""); + const [syncing, setSyncing] = useState(false); + const [syncError, setSyncError] = useState(null); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const storedUsername = useSelector((state) => state.username); + + useEffect(() => { + if (storedUsername) setUsernameInput(storedUsername); + }, [storedUsername]); + + useEffect(() => { + const checkCSVFile = async () => { + try { + const response = await api.get("/api/check-csv"); + if (response.data.fileExists) { + navigate("/PosterSelector"); + } + } catch (error) { + console.error("Error checking CSV file:", error); + } + }; + checkCSVFile(); + }, [navigate]); + + const { getRootProps, getInputProps } = useDropzone({ + onDrop: (acceptedFiles) => { + setFile(acceptedFiles[0]); + }, + maxFiles: 1, + accept: ".csv", + }); + + const handleUpload = async () => { + if (!file) return; + setUploading(true); + try { + const formData = new FormData(); + formData.append("file", file); + await api.post("/api/upload-csv", formData, { + headers: { "Content-Type": "multipart/form-data" }, + }); + dispatch(setDataSource('csv')); + navigate("/PosterSelector"); + } catch (error) { + console.error("Error processing diary:", error); + } finally { + setUploading(false); + } + }; + + const handleRssSync = async () => { + const username = usernameInput.trim(); + if (!username) return; + setSyncing(true); + setSyncError(null); + try { + const res = await api.post("/api/sync-rss", { username }); + dispatch(setUsername(username)); + if (res.data.fresh) dispatch(setDataSource('rss')); + navigate("/PosterSelector"); + } catch (err) { + const msg = err.response?.data?.error || "Could not fetch RSS feed."; + setSyncError(msg); + } finally { + setSyncing(false); + } + }; + + return ( + + +
+
+
+ logo +
+ + {/* CSV upload */} + + Upload Letterboxd Diary + +
+ + {file ? ( + <> + + + Your file has been uploaded: {file.name} + + + ) : ( + <> + + + Drag and drop a CSV file here or click to select + + + )} +
+ + {uploading ? ( + + ) : ( + + )} + + + {/* Divider */} +
+ + or + +
+ + {/* RSS sync */} + + Sync from Letterboxd RSS + +
+ { setUsernameInput(e.target.value); setSyncError(null); }} + onKeyDown={(e) => e.key === "Enter" && handleRssSync()} + InputProps={{ + startAdornment: ( + + letterboxd.com/ + + ), + }} + /> + +
+ {syncError && ( + {syncError} + )} + + Syncs your ~50 most recent diary entries via the public RSS feed. + Use CSV export for full history. + +
+
+ + + This tool allows you to easily import your Letterboxd diary and select + your favorite movie poster. Follow these simple steps: +
+ 1. Export your diary:{" "} + + Export your Letterboxd data + {" "} + and extract the diary.csv file from the ZIP. +
+ 2. Upload the CSV: Drop your diary.csv{" "} + file above or click to select it. +
+ 3. Poster Selection: After processing, you'll be + redirected to the calendar where you can browse and choose your + favorite poster for each movie. +
+
+ + + + v{VERSION} + + + • + + (e.currentTarget.style.opacity = 1)} + onMouseLeave={(e) => (e.currentTarget.style.opacity = 0.7)} + > + Hugyouu + + + +
+ ); +}; + +export default UploadDiary; diff --git a/src/services/action.js b/src/services/action.js index df1181e..e1d8d98 100644 --- a/src/services/action.js +++ b/src/services/action.js @@ -1,3 +1,10 @@ +export const SET_USERNAME = "SET_USERNAME"; +export const setUsername = (username) => ({ type: SET_USERNAME, payload: username }); + +export const SET_DATA_SOURCE = "SET_DATA_SOURCE"; +// source: 'csv' | 'rss' +export const setDataSource = (source) => ({ type: SET_DATA_SOURCE, payload: source }); + export const SELECT_POSTER = "SELECT_POSTER"; export const DESELECT_POSTER = "DESELECT_POSTER"; export const REMOVE_POSTER = "REMOVE_POSTER"; diff --git a/src/services/api.js b/src/services/api.js index b053a9d..f8261f3 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -1,7 +1,22 @@ import axios from "axios"; -const API_BASE_URL = "http://localhost:5000/api"; +export const getUserId = () => { + let id = localStorage.getItem("poster_picker_uid"); + if (!id) { + id = crypto.randomUUID(); + localStorage.setItem("poster_picker_uid", id); + } + return id; +}; -export const fetchMovies = () => axios.get(`${API_BASE_URL}/movies`); -export const downloadPoster = (movieId) => - axios.post(`${API_BASE_URL}/download-poster`, { movie_id: movieId }); +const api = axios.create({ + baseURL: process.env.REACT_APP_API_URL, + withCredentials: true, +}); + +api.interceptors.request.use((config) => { + config.headers["X-User-ID"] = getUserId(); + return config; +}); + +export default api; diff --git a/src/services/store.js b/src/services/store.js index 210f953..86f4c6f 100644 --- a/src/services/store.js +++ b/src/services/store.js @@ -1,7 +1,7 @@ import { legacy_createStore as createStore, combineReducers } from "redux"; import { persistStore, persistReducer } from "redux-persist"; import storage from "redux-persist/lib/storage"; -import {DESELECT_POSTER, REMOVE_ALL_POSTERS, REMOVE_POSTER, SELECT_POSTER} from "./action"; +import { DESELECT_POSTER, REMOVE_ALL_POSTERS, REMOVE_POSTER, SELECT_POSTER, SET_USERNAME, SET_DATA_SOURCE } from "./action"; const posterSelectionReducer = (state = {}, action) => { switch (action.type) { @@ -9,7 +9,7 @@ const posterSelectionReducer = (state = {}, action) => { const { movieId, posterId, watchedDate } = action.payload; return { ...state, - [movieId]: [...(state[movieId] || []), { posterId, watchedDate }], + [movieId]: [{ posterId, watchedDate }], }; } case DESELECT_POSTER: { @@ -38,9 +38,22 @@ const posterSelectionReducer = (state = {}, action) => { } }; +const usernameReducer = (state = "", action) => { + switch (action.type) { + case SET_USERNAME: return action.payload; + default: return state; + } +}; + +const dataSourceReducer = (state = null, action) => { + if (action.type === SET_DATA_SOURCE) return action.payload; + return state; +}; + const rootReducer = combineReducers({ posterSelections: posterSelectionReducer, - // other reducers... + username: usernameReducer, + dataSource: dataSourceReducer, }); const persistConfig = { diff --git a/src/styles/App.scss b/src/styles/App.scss index 632a0ff..11258aa 100644 --- a/src/styles/App.scss +++ b/src/styles/App.scss @@ -1,6 +1,7 @@ @use "./scss/uploadDiary"; @use "./scss/posterSelector"; @use "./scss/posterGallery"; +@use "./scss/selectionRecap"; @use "./scss/cart"; @use "./scss/navbar"; diff --git a/src/styles/scss/_navbar.scss b/src/styles/scss/_navbar.scss index 74095ac..fa847ee 100644 --- a/src/styles/scss/_navbar.scss +++ b/src/styles/scss/_navbar.scss @@ -1,32 +1,206 @@ -// _navbar.scss +@use "sass:color"; -// Variables de couleur et autres constantes $background-color: #1c1f23; -$icon-color: white; -$margin-right: 8px; // Correspondant à theme.spacing(2) +$primary-color: #00a346; +$text-white: #ffffff; +$text-gray: #667788; -.navbar { - top: auto !important; - bottom: 0; +.app-header { background-color: $background-color !important; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.08) !important; - .toolbar { + .header-toolbar { display: flex; justify-content: space-between; + align-items: center; + padding: 0 1.5rem; } - .back-button, - .cart-button, - .refresh-button { - color: $icon-color; + .header-left { + display: flex; + align-items: center; + gap: 10px; + cursor: pointer; + flex-shrink: 0; + + &:hover .header-title { + color: $primary-color; + } } - .refresh-button { - margin-right: $margin-right; + .header-logo { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; } - .back-button[disabled] { - opacity: 0.3; - pointer-events: none; + .header-title { + color: $text-white; + font-weight: 700; + letter-spacing: 0.02em; + transition: color 0.15s ease; + } + + .header-center { + position: absolute; + left: 50%; + transform: translateX(-50%); + max-width: 40%; + } + + .header-movie-name { + color: $text-white; + font-weight: 400; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .header-username { + color: $text-gray; + font-size: 0.85rem !important; + letter-spacing: 0.03em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &::before { + content: '@'; + opacity: 0.6; + } + } + + .header-actions { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .header-menu-btn { + display: none !important; + color: $text-gray !important; + &:hover { color: $text-white !important; } + } + + @media (max-width: 640px) { + .header-actions { display: none !important; } + .header-menu-btn { display: flex !important; } + .header-title { font-size: 1rem !important; } + .header-center { display: none; } + } + + .header-reset-btn { + color: $text-gray !important; + + &:hover { + color: $text-white !important; + } + } + + .header-recap-btn { + color: $text-gray !important; + + &:hover { + color: $text-white !important; + } + } + + .header-sync-btn { + color: $text-gray !important; + + &:hover { color: $text-white !important; } + + &--active { color: $primary-color !important; } + } + + .spin { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + .header-download-btn { + background-color: $primary-color !important; + color: $text-white !important; + font-weight: 600; + padding: 0.4rem 1rem; + border-radius: 4px; + text-transform: none; + font-size: 0.875rem; + + &:hover { + background-color: color.adjust($primary-color, $lightness: -8%) !important; + } + + &--loading { + background-color: color.adjust($primary-color, $lightness: -12%) !important; + cursor: default !important; + min-width: 140px; + } + } + + .header-download-progress { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 120px; + + span { + font-size: 0.8rem; + font-weight: 600; + color: $text-white; + line-height: 1; + } + } + + .header-progress-bar { + width: 100%; + border-radius: 2px; + height: 4px !important; + + .MuiLinearProgress-bar { + background-color: $text-white !important; + } + + &.MuiLinearProgress-root { + background-color: rgba(255, 255, 255, 0.3) !important; + } } } + +// Menu mobile (portail MUI, hors de .app-header) +.header-menu-paper { + background-color: #1c1f23 !important; + border: 1px solid #2c3038 !important; + border-radius: 6px !important; + min-width: 200px; + + .MuiList-root { padding: 4px 0; } +} + +.header-menu-item { + color: #ffffff !important; + font-size: 0.875rem !important; + gap: 10px; + padding: 10px 16px !important; + + &:hover { background-color: rgba(102, 119, 136, 0.2) !important; } + &.Mui-disabled { opacity: 0.4 !important; color: #ffffff !important; } + + &--danger { + color: #ef9a9a !important; + margin-top: 4px; + border-top: 1px solid #2c3038; + } +} + +.header-menu-icon { + opacity: 0.6; + flex-shrink: 0; +} diff --git a/src/styles/scss/_posterGallery.scss b/src/styles/scss/_posterGallery.scss index cb8cb1f..73a5652 100644 --- a/src/styles/scss/_posterGallery.scss +++ b/src/styles/scss/_posterGallery.scss @@ -6,7 +6,7 @@ $hover-scale: 1.05; $loading-height: 50vh; .poster-gallery { - margin-top: 2rem; + margin-top: 5rem; margin-bottom: 2rem; .content-paper { @@ -15,6 +15,49 @@ $loading-height: 50vh; background-color: $background-dark; } + .lang-filter-bar { + display: flex; + justify-content: flex-end; + margin-bottom: 1.25rem; + } + + .lang-toggle-group { + border: 1px solid #2c3038 !important; + border-radius: 4px !important; + overflow: hidden; + } + + .lang-toggle-btn { + color: #667788 !important; + border: none !important; + border-left: 1px solid #2c3038 !important; + padding: 4px 14px !important; + font-size: 0.75rem !important; + font-weight: 600 !important; + text-transform: none !important; + min-width: unset !important; + background-color: transparent !important; + + &:first-child { + border-left: none !important; + } + + &:hover { + color: #ffffff !important; + background-color: rgba(102, 119, 136, 0.15) !important; + } + + &.Mui-selected { + color: #ffffff !important; + background-color: #667788 !important; + } + } + + .no-posters-msg { + color: #667788; + padding: 3rem 0; + } + .title { color: $text-white; margin-bottom: 1.5rem; @@ -24,30 +67,19 @@ $loading-height: 50vh; .poster-card { position: relative; - height: 100%; - display: flex; - flex-direction: column; background-color: transparent; box-shadow: none; cursor: pointer; - transition: all 0.3s ease; + transition: transform 0.3s ease; + border-radius: 4px; + overflow: hidden; &:hover { transform: scale($hover-scale); } &.selected-poster { - border: 4px solid $primary-color; - border-radius: 4px; - } - - .poster-image { - padding-top: 150%; - background-size: contain; - background-position: center; - background-repeat: no-repeat; - border-radius: 4px; - transition: transform 0.3s ease-in-out; + outline: 4px solid $primary-color; } .check-icon { @@ -61,6 +93,46 @@ $loading-height: 50vh; } } + .poster-aspect-wrapper { + position: relative; + padding-top: 150%; + border-radius: 4px; + overflow: hidden; + } + + .poster-skeleton { + position: absolute; + inset: 0; + background: linear-gradient(90deg, #1c1f23 25%, #2a2e33 50%, #1c1f23 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.4s infinite; + transition: opacity 0.3s ease; + + &--hidden { + opacity: 0; + pointer-events: none; + } + } + + .poster-image-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.4s ease; + + &--loaded { + opacity: 1; + } + } + + @keyframes skeleton-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + .loading-container { display: flex; justify-content: center; diff --git a/src/styles/scss/_posterSelector.scss b/src/styles/scss/_posterSelector.scss index 4d5101f..a055f94 100644 --- a/src/styles/scss/_posterSelector.scss +++ b/src/styles/scss/_posterSelector.scss @@ -1,134 +1,314 @@ @use "sass:color"; -// Variables $primary-color: #00a346; $secondary-color: #667788; $background-dark: #1c1f23; $text-white: #ffffff; $text-gray: #667788; -$border-color: #667788; +$border-color: #2c3038; $hover-color: rgba(102, 119, 136, 0.2); .poster-selector { - margin-top: 2rem; + margin-top: 5rem; margin-bottom: 2rem; .content-paper { padding: 1.5rem; border-radius: 4px; - background-color: $background-dark !important; + background-color: $background-dark; + max-width: calc((120vh - 340px) * 7 / 9 + 3rem); + margin: 0 auto; } .title { - margin-bottom: 1.5rem; + margin-bottom: 1rem; color: $text-white; } - .username { - color: $primary-color; - margin-bottom: 1rem; - } - - .movies-list { - max-height: 800px; - min-height: 900px; - padding: 0; - } - - .movie-item { - padding: 0 2rem; - margin-bottom: 1rem; - transition: background-color 0.3s ease; + // Calendar navigation + .calendar-nav { display: flex; align-items: center; justify-content: space-between; - flex-wrap: nowrap; + margin-bottom: 1.5rem; + } + + .calendar-nav-center { + display: flex; + align-items: center; + gap: 0.25rem; + } + + .calendar-nav-btn { + color: $text-white !important; &:hover { - background-color: $hover-color; - } - - &:not(:last-child) { - border-bottom: 1px solid $border-color; - padding-top: 0.25rem; - padding-bottom: 0.25rem; - } - - // Override MUI ListItem styles - &.MuiListItem-root { - padding: 0.5rem !important; - } - - .MuiGrid-container { - display: flex; - justify-content: space-between; - width: 100%; - } - - .movie-poster { - width: 50px; - height: 75px; - object-fit: cover; - margin-right: 1rem; - border-radius: 4px; - } - - .movie-info { - flex-grow: 1; - max-width: calc(100% - 110px); - } - - .movie-title { - color: $text-white; - font-family: "TiemposTextWeb-Semibold, Georgia, serif"; - font-size: 1.38461538rem; - font-weight: 400; - transition: color 0.3s ease; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - &:hover { - color: $primary-color; - } - } - - .movie-year { - color: $text-gray; - } - - .watched-date { - color: $text-gray; - } - - .watched-day { - font-size: 2rem; - line-height: 1; - margin-bottom: 0.25rem; - } - - .watched-month { - font-size: 0.875rem; + background-color: $hover-color !important; } } - .pagination-container { + .calendar-month-label { + color: $text-white; + min-width: 160px; + text-align: center; + } + + .calendar-today-btn { + color: $text-gray !important; + font-size: 0.75rem !important; + text-transform: none !important; + padding: 2px 8px !important; + border: 1px solid $text-gray !important; + border-radius: 4px !important; + min-width: unset !important; + + &:hover { + color: $text-white !important; + border-color: $text-white !important; + } + } + + .calendar-month-selects { display: flex; - justify-content: center; - margin-top: 1.5rem; + align-items: center; + gap: 4px; + border: 1px solid $border-color; + border-radius: 4px; + padding: 0 4px; - // Override MUI Pagination styles - .MuiPagination-root { - .MuiPaginationItem-root { + &:hover { + border-color: $text-gray; + } + } + + .calendar-select { + color: $text-gray !important; + font-size: 0.8rem !important; + + .MuiSelect-root { + padding: 4px 24px 4px 6px !important; + } + + .MuiSelect-icon { + color: $text-gray; + } + + &:hover { + color: $text-white !important; + } + } + + .calendar-select-menu { + background-color: #1c1f23 !important; + color: $text-white !important; + border: 1px solid $border-color; + + .MuiMenuItem-root { + font-size: 0.85rem; + color: $text-gray; + + &:hover, &.Mui-selected { + background-color: rgba(102, 119, 136, 0.2) !important; color: $text-white; - - &.Mui-selected { - background-color: $secondary-color; - } } } } + .rss-notice { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: $text-gray; + background-color: rgba(102, 119, 136, 0.08); + border: 1px solid rgba(102, 119, 136, 0.2); + border-radius: 4px; + padding: 6px 10px; + margin-bottom: 1rem; + } + + .rss-notice-icon { + font-size: 0.8rem; + flex-shrink: 0; + } + + .rss-notice-link { + color: $primary-color; + cursor: pointer; + text-decoration: underline; + + &:hover { color: color.scale($primary-color, $lightness: 15%); } + } + + // Calendar grid + .calendar-grid-wrapper { + position: relative; + } + + .calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 6px; + max-width: calc((120vh - 340px) * 7 / 9); + min-width: 280px; + margin: 0 auto; + } + + .calendar-day-header { + color: $text-gray; + font-size: 0.75rem; + font-weight: 600; + text-align: center; + text-transform: uppercase; + padding: 0.25rem 0; + letter-spacing: 0.05em; + } + + .calendar-cell { + position: relative; + border-radius: 4px; + border: 1px solid $border-color; + overflow: hidden; + background-color: #14181c; + aspect-ratio: 2/3; + min-height: 14vh; + + &--empty { + border-color: transparent; + background-color: transparent; + } + + &--future { + background-color: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.04); + + .calendar-day-number { + opacity: 0.25; + } + } + + &--today { + border-color: $secondary-color; + + .calendar-day-number { + color: $text-white; + font-weight: 700; + } + } + + &--has-movie { + cursor: pointer; + border-color: transparent; + + &:hover .calendar-poster-overlay { + opacity: 1; + } + + &:hover .calendar-day-number { + color: $text-white; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8); + } + } + } + + .calendar-day-number { + position: absolute; + top: 4px; + left: 6px; + font-size: 0.7rem; + color: $text-gray; + z-index: 2; + line-height: 1; + } + + .calendar-poster-wrapper { + position: absolute; + inset: 0; + } + + .calendar-cell-skeleton { + position: absolute; + inset: 0; + background: linear-gradient(90deg, #1c1f23 25%, #252930 50%, #1c1f23 75%); + background-size: 200% 100%; + animation: calendar-shimmer 1.4s infinite; + } + + @keyframes calendar-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + + .calendar-poster { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + .calendar-poster-overlay { + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.2) 60%, transparent 100%); + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 6px; + opacity: 0; + transition: opacity 0.2s ease; + } + + .calendar-movie-title { + color: $text-white; + font-size: 0.65rem; + font-weight: 600; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .calendar-movie-year { + color: $text-gray; + font-size: 0.6rem; + margin-top: 2px; + } + + .calendar-more-badge { + position: absolute; + top: 4px; + right: 4px; + background-color: $primary-color; + color: $text-white; + font-size: 0.6rem; + font-weight: 700; + padding: 1px 4px; + border-radius: 3px; + z-index: 2; + } + + .calendar-selected-badge { + position: absolute; + bottom: 4px; + right: 4px; + background-color: $primary-color; + color: $text-white; + width: 18px; + height: 18px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + } + + .calendar-cell--selected { + box-shadow: 0 0 0 2px $primary-color; + border-radius: 4px; + } + + // Shared states .progress-container { display: flex; justify-content: center; @@ -148,12 +328,23 @@ $hover-color: rgba(102, 119, 136, 0.2); } .no-movies-container { + position: absolute; + inset: 0; + z-index: 10; display: flex; flex-direction: column; align-items: center; justify-content: center; - height: 400px; color: $text-white; + background: rgba(28, 31, 35, 0.7); + backdrop-filter: blur(6px); + border-radius: 4px; + animation: fade-in 0.3s ease; + } + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } } .gif-container { @@ -193,3 +384,142 @@ $hover-color: rgba(102, 119, 136, 0.2); } } } + +// Responsive +@media (max-width: 768px) { + .poster-selector { + margin-top: 3.5rem !important; + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + + .poster-selector .content-paper { + padding: 0.75rem !important; + max-width: 100% !important; + } + + // Nav : deux rangées + // Rangée 1 : Today (gauche) | chevrons + label (droite) + // Rangée 2 : selects centrés + .poster-selector .calendar-nav { + flex-wrap: wrap; + row-gap: 8px; + margin-bottom: 0.75rem; + } + + .poster-selector .calendar-today-btn { + order: 1; + } + + .poster-selector .calendar-nav-center { + order: 2; + flex: 1; + justify-content: flex-end; + } + + .poster-selector .calendar-month-label { + min-width: unset !important; + font-size: 0.9rem !important; + } + + .poster-selector .calendar-month-selects { + order: 3; + width: 100%; + justify-content: center !important; + border: none !important; + border-top: 1px solid #2c3038 !important; + border-radius: 0 !important; + padding: 6px 0 0 !important; + gap: 8px !important; + } + + // Grille + .poster-selector .calendar-grid { + gap: 3px; + max-width: 100%; + min-width: unset; + } + + .poster-selector .calendar-day-header { + font-size: 0.6rem; + padding: 0.15rem 0; + } + + .poster-selector .calendar-cell { + min-height: unset; // aspect-ratio 2/3 contrôle la hauteur + border-radius: 3px; + } + + .poster-selector .calendar-day-number { + font-size: 0.6rem; + } + + .poster-selector .calendar-movie-title { + font-size: 0.55rem; + } + + .poster-selector .calendar-more-badge, + .poster-selector .calendar-selected-badge { + display: none; + } +} + +// Popover multi-films (rendu en portal, hors de .poster-selector) +.multi-movie-popover { + background-color: #1c1f23 !important; + border: 1px solid #2c3038; + border-radius: 6px !important; + overflow: hidden; + min-width: 220px; +} + +.multi-movie-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid #2c3038; + + &:last-child { border-bottom: none; } + + &:hover { background-color: rgba(102, 119, 136, 0.2); } +} + +.multi-movie-poster { + width: 36px; + height: 54px; + object-fit: cover; + border-radius: 2px; + flex-shrink: 0; + + &--empty { background-color: #2c3038; } +} + +.multi-movie-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.multi-movie-title { + color: #ffffff; + font-size: 0.8rem; + font-weight: 500; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.multi-movie-year { + color: #667788; + font-size: 0.7rem; +} + +.multi-movie-check { + color: #00a346 !important; + flex-shrink: 0; +} diff --git a/src/styles/scss/_selectionRecap.scss b/src/styles/scss/_selectionRecap.scss new file mode 100644 index 0000000..29cf704 --- /dev/null +++ b/src/styles/scss/_selectionRecap.scss @@ -0,0 +1,187 @@ +@use "sass:color"; + +$primary-color: #00a346; +$background-dark: #1c1f23; +$text-white: #ffffff; +$text-gray: #667788; +$border-color: #2c3038; + +.selection-recap { + margin-top: 5rem; + margin-bottom: 3rem; + + .recap-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 2.5rem; + } + + .recap-back-btn { + color: $text-gray !important; + &:hover { color: $text-white !important; } + } + + .recap-title { + flex: 1; + color: $text-white; + font-weight: 600; + } + + .recap-download-btn { + background-color: $primary-color !important; + color: $text-white !important; + font-weight: 600; + text-transform: none !important; + padding: 0.4rem 1rem; + border-radius: 4px; + font-size: 0.875rem; + white-space: nowrap; + + &:hover { background-color: color.adjust($primary-color, $lightness: -8%) !important; } + + &--loading { + background-color: color.adjust($primary-color, $lightness: -12%) !important; + cursor: default !important; + min-width: 150px; + } + + &.Mui-disabled { + color: $text-white !important; + opacity: 0.8 !important; + } + } + + .recap-download-progress { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + width: 120px; + + span { + font-size: 0.8rem; + font-weight: 600; + color: $text-white; + line-height: 1; + } + } + + .recap-progress-bar { + width: 100%; + border-radius: 2px; + height: 4px !important; + + .MuiLinearProgress-bar { background-color: $text-white !important; } + &.MuiLinearProgress-root { background-color: rgba(255, 255, 255, 0.3) !important; } + } + + .recap-month-section { + margin-bottom: 2.5rem; + } + + .recap-month-label { + color: $text-gray; + font-size: 0.8rem !important; + font-weight: 700 !important; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 1rem !important; + padding-bottom: 0.5rem; + border-bottom: 1px solid $border-color; + } + + .recap-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: 10px; + } + + .recap-card { + cursor: pointer; + border-radius: 4px; + overflow: hidden; + + &:hover .recap-overlay { opacity: 1; } + &:hover .recap-poster { transform: scale(1.04); } + } + + .recap-poster-wrapper { + position: relative; + padding-top: 150%; + overflow: hidden; + border-radius: 4px; + background-color: #14181c; + } + + .recap-poster { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.25s ease; + } + + .recap-overlay { + position: absolute; + inset: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.15) 55%, transparent 100%); + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: 8px; + opacity: 0; + transition: opacity 0.2s ease; + } + + .recap-movie-title { + color: $text-white; + font-size: 0.68rem; + font-weight: 600; + line-height: 1.2; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .recap-movie-year { + color: rgba(255, 255, 255, 0.55); + font-size: 0.62rem; + margin-top: 2px; + } + + .recap-check-badge { + position: absolute; + top: 6px; + right: 6px; + background-color: $primary-color; + color: $text-white; + width: 18px; + height: 18px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + } + + .recap-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 60vh; + gap: 1.5rem; + color: $text-gray; + + .MuiTypography-root { color: $text-gray; } + } + + .recap-go-btn { + background-color: $primary-color !important; + color: $text-white !important; + text-transform: none !important; + } +} diff --git a/src/styles/scss/_uploadDiary.scss b/src/styles/scss/_uploadDiary.scss index 778be6d..6505ece 100644 --- a/src/styles/scss/_uploadDiary.scss +++ b/src/styles/scss/_uploadDiary.scss @@ -1,12 +1,36 @@ -.upload-diary { - padding: 3rem; +.upload-page { + min-height: 100vh; + display: flex; + flex-direction: column; background-color: #14181c; - min-height: calc(100vh - 60px); +} + +.upload-diary { + flex: 1; + padding: 3rem; + padding-top: 5rem; display: flex !important; flex-direction: column; align-items: center; justify-content: center; + @media (max-width: 600px) { + padding: 1.25rem; + padding-top: 4.5rem; + justify-content: flex-start; + } + + .upload-card-wrapper { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + + @media (max-width: 600px) { + min-height: calc(100svh - 80px); + } + } + .upload-card { width: 100%; max-width: 600px; @@ -17,6 +41,11 @@ padding-top: 4rem; position: relative; + @media (max-width: 600px) { + padding: 1.25rem; + padding-top: 3rem; + } + .logo-container { position: absolute; top: -80px; @@ -24,11 +53,20 @@ transform: translate(-50%); z-index: 10; + @media (max-width: 600px) { + top: -55px; + } + .logo { width: 150px; height: 150px; border-radius: 50%; object-fit: cover; + + @media (max-width: 600px) { + width: 100px; + height: 100px; + } } } @@ -36,35 +74,11 @@ margin-bottom: 1.5rem; font-weight: bold; color: #ffffff; - } - .username-field { - margin-bottom: 1.5rem; - - .MuiOutlinedInput-root { - color: #ffffff; - - fieldset { - border-color: #00a346 !important; - } - - &:hover fieldset { - border-color: #00a346 !important; - } - - &.Mui-focused fieldset { - border-color: #1caff2 !important; - } + @media (max-width: 600px) { + font-size: 1.4rem !important; + margin-bottom: 1rem; } - - .MuiInputLabel-root { - color: #ffffff !important; - } - } - - .or-text { - color: #ffffff; - margin-bottom: 1rem; } .dropzone { @@ -74,6 +88,10 @@ text-align: center; cursor: pointer; + @media (max-width: 600px) { + padding: 1.25rem; + } + &:hover { background-color: #1f252a; } @@ -82,11 +100,20 @@ margin-bottom: 1rem; color: #00a346; font-size: 4rem; + + @media (max-width: 600px) { + font-size: 2.5rem; + margin-bottom: 0.5rem; + } } .dropzone-text { margin-bottom: 1rem; color: #ffffff; + + @media (max-width: 600px) { + font-size: 0.9rem !important; + } } } @@ -94,6 +121,101 @@ margin-top: 1rem; } + .upload-divider { + display: flex; + align-items: center; + gap: 0.75rem; + margin: 1.75rem 0 1.25rem; + + @media (max-width: 600px) { + margin: 1.25rem 0 1rem; + } + } + + .upload-divider-line { + flex: 1; + height: 1px; + background-color: #2c3038; + } + + .upload-divider-text { + color: #526e89; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + } + + .rss-label { + color: #ffffff !important; + font-size: 0.9rem !important; + font-weight: 600 !important; + margin-bottom: 0.75rem !important; + } + + .rss-row { + display: flex; + gap: 8px; + align-items: center; + } + + .rss-input { + flex: 1; + min-width: 0; + + .MuiOutlinedInput-root { + background-color: #14181c; + border-radius: 4px; + color: #ffffff; + font-size: 0.875rem; + + fieldset { border-color: #2c3038; } + &:hover fieldset { border-color: #526e89; } + &.Mui-focused fieldset { border-color: #00a346; } + + input { padding: 8px 10px 8px 4px; color: #ffffff; } + input::placeholder { color: #526e89; } + } + } + + .rss-url-prefix { + color: #526e89; + font-size: 0.8rem; + white-space: nowrap; + user-select: none; + + @media (max-width: 400px) { + display: none; + } + } + + .rss-sync-button { + background-color: #00a346 !important; + color: #ffffff !important; + font-weight: 700 !important; + text-transform: none !important; + padding: 7px 16px !important; + border-radius: 4px !important; + white-space: nowrap; + font-size: 0.875rem !important; + + &:hover { background-color: #008a39 !important; } + &:disabled { opacity: 0.6 !important; } + } + + .rss-note { + color: #526e89 !important; + font-size: 0.72rem !important; + margin-top: 0.5rem !important; + line-height: 1.4 !important; + } + + .rss-error { + color: #e57373 !important; + font-size: 0.78rem !important; + margin-top: 0.4rem !important; + } + .submit-button { color: #fff; font-size: 0.8rem; @@ -125,6 +247,15 @@ color: #456; margin-top: 2rem; padding: 1.5rem; + max-width: 600px; + width: 100%; + + @media (max-width: 600px) { + padding: 1rem 0.25rem; + margin-top: 1.25rem; + font-size: 0.85rem !important; + // display: block; + } } } @@ -142,7 +273,7 @@ .link { text-decoration: none; - color: inherit + color: inherit; } } -} \ No newline at end of file +}