diff --git a/.gitignore b/.gitignore index a547bf3..7ceb59f 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +.env diff --git a/README.md b/README.md index a7ed400..3f4f2cc 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,126 @@ ## Virtual machine -The webpage can be accessed through the group’s virtual machine at: +The application is available on the group's virtual machine at: -- http://it2810-12.idi.ntnu.no/project2/ +http://it2810-12.idi.ntnu.no/project2/ -The project uses a MongoDB database hosted on the group’s virtual machine. The GraphQL backend is configured to run on port `3001`. +The MongoDB database and GraphQL backend are hosted directly on the virtual machine. + +The backend is configured to run on port `3001`. ## Overview -Artex is a searchable artwork catalogue developed as part of IT2810. The application allows users to explore artwork stored in our own database through a React frontend and a GraphQL backend. +Artex is a searchable artwork catalogue developed as part of IT2810 Web Development at NTNU. + +The application allows users to explore artworks from the Metropolitan Museum of Art collection through a React frontend connected to our own GraphQL backend and MongoDB database. + +Users can search, filter and sort the artwork catalogue, browse results using pagination and open individual artworks to view additional information. Users can also create an account, log in, create personal galleries and add artworks to their galleries. User-generated gallery data is stored persistently in the database. + +For the third delivery, the project represents a first complete prototype of the application, with particular focus on catalogue functionality, frontend/backend integration, accessibility and sustainable web development. + +## Main functionality + +The application currently supports: + +- Searching artworks by title or artist +- Case-insensitive search +- Search submission through a search button to avoid unnecessary requests +- Artist filtering with debounced artist search +- Sorting by title, artist and year +- Server-side filtering and sorting +- Pagination of artwork results +- Individual artwork detail pages +- Responsive layouts for different screen sizes +- User registration and login +- Protected profile pages for authenticated users +- Creation of personal galleries +- Adding artworks to galleries +- Persistent user-generated gallery data +- Profile and cover image customization +- Loading, empty and error states for relevant functionality + +Sorting, filtering and pagination are performed through the backend rather than only on the data currently loaded in the browser. This allows the solution to scale to larger datasets. + +## Technologies + +### Frontend + +- React +- TypeScript +- Vite +- Tailwind CSS +- React Router +- Zustand +- React Select +- Lucide React + +### Backend + +- Node.js +- TypeScript +- GraphQL +- Apollo Server +- MongoDB + +### Development tools + +- ESLint +- Prettier +- Git +- GitLab + +## Technical choices + +The project is divided into a frontend, backend and database. The frontend is built with React and TypeScript using Vite, while the backend is built with Node.js, TypeScript, GraphQL and Apollo Server. MongoDB is used to store artwork data, users and galleries. + +The frontend communicates with the backend through GraphQL. This allows the client to request only the fields it needs and makes it possible to perform pagination, search, filtering and sorting on the backend instead of loading the complete dataset into the browser. + +Artwork results are loaded using pagination. The frontend sends a `limit` and `offset` to the GraphQL backend, and the backend retrieves only the requested portion of the dataset from MongoDB. The catalogue currently displays 12 artworks per page. This approach was chosen with scalability in mind, since the application should be able to handle a significantly larger dataset without transferring all artwork data to the client at once. + +Filtering and sorting are also performed by the backend so that they apply to the complete result set rather than only the artworks currently displayed in the browser. The application supports sorting by title, artist and year in ascending or descending order. + +The main artwork search is submitted explicitly instead of sending a request for every character typed into the search field. Artist search uses a debounce before requesting matching artists from the backend. These choices reduce unnecessary network requests while still providing a responsive user experience. + +Zustand is used for shared frontend state. Authentication state, including the current user and authentication token, is stored in a central Zustand store and shared across login, registration, protected routes, profile navigation and authenticated gallery functionality. The store is persisted using `sessionStorage`, allowing the user to remain logged in when refreshing the page during the same browser session. + +State that only belongs to a single component remains managed using React state. Form input, modal visibility, loading states and locally fetched gallery data are therefore kept local instead of being moved into global state unnecessarily. + +Artex also includes persistent user-generated data through personal galleries. Authenticated users can create galleries and add artworks to them. Gallery information and artwork IDs are stored in MongoDB, so the data remains available after refreshing or logging in again. When an artwork is added to a gallery, set-based database updates are used to avoid storing the same artwork multiple times in the same gallery. + +The design is based on our Figma prototype and is implemented responsively so that the layout adapts to different screen sizes. The frontend is divided into reusable components and hooks to separate responsibilities and make the code easier to maintain and test. + +## Accessibility + +Accessibility has been considered throughout the frontend implementation. + +Examples of accessibility choices include semantic HTML elements such as `main`, `section`, `form`, `button` and links, labels connected to form inputs, `aria-label` for icon-only controls and accessible attributes for expandable filters and dialogs. + +The catalogue search uses `role="search"`, while relevant feedback is presented using `role="alert"` and `role="status"`. Meaningful images use alternative text, while decorative images use empty alternative text where appropriate. Interactive elements use keyboard-accessible buttons and links, and visible focus styles are included for relevant controls. + +The application also provides loading states, empty-result messages and error feedback so that users receive clear information about what is happening. + +Accessibility will continue to be reviewed throughout the remaining development process, particularly keyboard navigation, focus handling in dialogs and general usability. -The application is based on artwork data from the Metropolitan Museum of Art collection. Artwork information is stored in a MongoDB database and accessed through our own GraphQL API. +## Sustainable web development -For the second delivery, the main focus has been establishing a working connection between the frontend, backend and database. The application currently retrieves artwork data from the database and presents the results in a responsive grid. Pagination is used to load a limited number of artworks at a time instead of loading the complete dataset into the client. +Sustainable web development has influenced several technical and design choices in Artex. -The application will be developed further with search, filtering, sorting, artwork details and persistent user-generated data in later deliveries. +Pagination ensures that only a limited number of artworks are retrieved at a time instead of transferring the complete database to the browser. Server-side filtering and sorting further reduce unnecessary client-side processing and data transfer. -## Get started +The main artwork search is triggered by form submission rather than every keyboard input, while artist search uses debouncing. This reduces unnecessary network requests. + +GraphQL allows each view to request only the fields it needs. The catalogue view therefore retrieves only the information required for artwork cards, while additional artwork information is fetched when the user opens an individual detail page. + +Artwork listings use smaller artwork image variants where available rather than unnecessarily requesting higher-resolution images. Missing images are handled using placeholders. + +Together, these choices reduce network traffic, browser memory usage and unnecessary client-side computation while also improving the scalability of the application. + +## Getting started Before running the project, make sure Node.js and npm are installed. -The project is divided into separate `frontend` and `backend` directories. +The repository contains separate `frontend` and `backend` directories. ### Frontend @@ -33,21 +132,25 @@ cd frontend npm install ``` -Start the development server with: +Start the development server: ```bash npm run dev ``` -The webpage will then be available at the local URL provided in the terminal, normally: +The application will normally be available at: -- http://localhost:5173/ +```text +http://localhost:5173/ +``` ### Database connection -The MongoDB database is hosted on the group’s virtual machine. When running the backend locally, an SSH tunnel is used to connect to the database. +The MongoDB database is hosted on the group's virtual machine. -Open a terminal and run: +When running the backend locally, an SSH tunnel can be used to connect to the database. + +Open a separate terminal and run: ```bash ssh -N -L 27018:127.0.0.1:27017 @it2810-12.idi.ntnu.no @@ -57,78 +160,86 @@ Replace `` with your NTNU username and keep the terminal open while ru ### Backend -Open a new terminal, navigate to the backend directory and install the dependencies: +Navigate to the backend directory and install the dependencies: ```bash cd backend npm install ``` -Start the backend with the MongoDB connection through the SSH tunnel: +The backend requires a `JWT_SECRET` for authentication. + +Create a `.env` file in the backend directory and add: + +```env +JWT_SECRET= +``` + +A random secret can for example be generated with: + +```bash +openssl rand -hex 32 +``` + +Start the backend while connected to the database through the SSH tunnel: ```bash MONGODB_URI=mongodb://127.0.0.1:27018 npm run dev ``` -When the connection is successful, the terminal should display: +The GraphQL backend is available locally at: ```text -Connected to MongoDB -Server running at http://127.0.0.1:3001/ +http://localhost:3001/ ``` -The GraphQL API will then be available at: +To run the complete application locally, the SSH tunnel, backend and frontend must run at the same time. -- http://localhost:3001/ +## Code quality and testing -To run the application locally, the SSH tunnel, backend and frontend must run at the same time. +The project uses ESLint and Prettier to maintain consistent and readable code. -## Developed by - -- Emma Fredriksen -- Mina Ellingsen -- Karen Skauge -- Gurveer Singh +Frontend linting can be checked with: -## Technologies +```bash +cd frontend +npm run lint +``` -The project is developed using the following technologies and tools: +Backend linting can be checked with: -- React -- Vite -- TypeScript -- Tailwind CSS -- React Router -- Lucide React -- Node.js -- GraphQL -- Apollo Server -- MongoDB -- ESLint -- Prettier +```bash +cd backend +npm run lint +``` -## Technical choices +Formatting can be checked with: -The application is divided into a frontend, backend and database. The frontend is built with React and TypeScript using Vite, while the backend is built with Node.js, TypeScript, GraphQL and Apollo Server. MongoDB is used to store the artwork data. +```bash +npx prettier . --check +``` -The frontend is divided into smaller React components to separate responsibilities and make the code easier to understand, maintain and test. Components such as the hero section, artwork cards, artwork grid and pagination can be developed and reused independently. +The application is structured into pages, reusable components, hooks, stores and types to separate responsibilities and make the code easier to maintain and test. -GraphQL is used for communication between the frontend and backend. This allows the client to request only the fields it needs and makes it possible to handle functionality such as pagination, search, filtering and sorting on the backend instead of loading the full dataset into the browser. +Testing will be expanded further towards the final delivery. The final project will include component testing using Vitest, API testing and automated end-to-end testing of important user flows. -Artwork results are loaded using pagination. The frontend sends a `limit` and `offset` to the GraphQL backend, and the backend retrieves only the corresponding artworks from the database. This approach was chosen with scalability in mind, since the application should be able to handle a much larger dataset without transferring all artwork data to the client at once. +For the third delivery, the primary focus has been completing and validating the functional prototype, including search, pagination, filtering, sorting, authentication, galleries, accessibility and sustainable development choices. -Artwork images use lazy loading so images are loaded when needed instead of all at once. Missing or unavailable images are handled by displaying a placeholder. These choices reduce unnecessary data transfer and are also part of our approach to sustainable web development. +## Use of AI -React Router is used for navigation between pages. Some navigation is temporary for the second delivery. For example, the profile icon currently leads to the login page and will be updated when the final profile and authentication flow is implemented. +AI has been used as a supporting tool during the development of Artex. -The design is based on our Figma prototype and is implemented responsively so that the layout adapts to different screen sizes. Accessibility is considered by using semantic HTML, appropriate alternative text and accessible labels for controls where needed. Accessibility and responsive behavior will be developed and tested further in later deliveries. +It has mainly been used for discussing implementation approaches, troubleshooting development and configuration issues, explaining unfamiliar technologies and concepts, reviewing code and suggesting improvements, discussing accessibility and sustainable web development, supporting documentation and wording, and identifying possible edge cases. -## Use of AI +AI-generated suggestions are reviewed and adapted before being included in the project. The group aims to understand and be able to explain all code that is committed. -During development, AI has mainly been used as a supporting tool for discussing implementation approaches, troubleshooting errors, reviewing code, explaining unfamiliar technologies and concepts, and improving formulations in the README. +Issues, branches, pull requests and code reviews are used to maintain control over the development process and ensure that changes are reviewed and understood by the group. -We use AI as a coach rather than relying on generated solutions without understanding them. Suggestions are reviewed and adjusted before being included in the project, and we aim to understand and be able to explain the code we commit. +AI is therefore used primarily as a development and learning aid rather than as a replacement for the group's own implementation and technical decisions. -AI has, among other things, been used to help troubleshoot the local development setup between the React frontend, GraphQL backend and MongoDB database, discuss responsive design and accessibility, review possible implementation choices, and help formulate parts of the project documentation. +## Developed by -We use issues, pull requests and code reviews to maintain control over the development process and to make sure changes are understood and reviewed by the group. Our use of AI will continue to be documented and updated as the project develops. \ No newline at end of file +- Emma Fredriksen +- Mina Ellingsen +- Karen Skauge +- Gurveer Singh \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 39e0833..fa383ab 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,17 +11,21 @@ "dependencies": { "@apollo/server": "^5.5.1", "bcryptjs": "^3.0.3", + "dotenv": "^18.0.9", "graphql": "^16.14.2", + "jsonwebtoken": "^9.0.3", "mongodb": "^7.7.0" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^26.6.5", "eslint": "^10.13.0", "prettier": "^3.9.10", "tsx": "^4.23.15", "typescript": "^5.9.3", - "typescript-eslint": "^8.71.2" + "typescript-eslint": "^8.71.2", + "vitest": "^5.0.5" } }, "node_modules/@apollo/cache-control-types": { @@ -981,6 +985,34 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@keyv/bigmap": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", @@ -1014,6 +1046,17 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1077,6 +1120,302 @@ "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause" }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1098,12 +1437,30 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/long": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.6.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", @@ -1359,6 +1716,44 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@whatwg-node/promise-helpers": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-2.0.0.tgz", @@ -1411,6 +1806,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-retry": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", @@ -1455,18 +1860,18 @@ } }, "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^2.0.0", + "content-type": "^2.1.0", "debug": "^4.4.3", "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.15.2", + "qs": "^6.16.1", "raw-body": "^3.0.2", "type-is": "^2.1.0" }, @@ -1513,6 +1918,12 @@ "node": ">=20.19.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1583,6 +1994,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/content-type": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", @@ -1686,6 +2107,32 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.9.tgz", + "integrity": "sha512-3fVOW4cB02R066PUSwn8O4TJyz9NckQl7F81blIbaq9jKBm2tSmOKky7ZHIXzGvkVPXCSn6jkpziBPZdGFs8Tw==", + "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1700,6 +2147,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1733,6 +2189,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -1951,6 +2414,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1961,6 +2434,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2354,61 +2837,377 @@ "which-typed-array": "^1.1.16" }, "engines": { - "node": ">= 0.4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/locate-path": { @@ -2427,6 +3226,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -2461,6 +3302,16 @@ "node": "20 || >=22" } }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2595,6 +3446,26 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2652,6 +3523,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2743,6 +3628,14 @@ "node": ">=8" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, "node_modules/picomatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", @@ -2765,6 +3658,36 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2821,9 +3744,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -2860,6 +3783,41 @@ "node": ">= 4" } }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2890,7 +3848,6 @@ "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3037,6 +3994,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -3055,6 +4023,33 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -3276,6 +4271,168 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -3344,6 +4501,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/backend/package.json b/backend/package.json index 8631718..366fb50 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,13 +2,14 @@ "name": "backend", "version": "1.0.0", "description": "", - "main": "dist/index.js", + "main": "dist/server.js", "scripts": { - "dev": "tsx watch src/index.ts", + "dev": "tsx watch src/server.ts", "build": "tsc", - "start": "node dist/index.js", + "start": "node dist/server.js", "lint": "eslint .", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "keywords": [], "author": "", @@ -17,16 +18,20 @@ "dependencies": { "@apollo/server": "^5.5.1", "bcryptjs": "^3.0.3", + "dotenv": "^18.0.9", "graphql": "^16.14.2", + "jsonwebtoken": "^9.0.3", "mongodb": "^7.7.0" }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^26.6.5", "eslint": "^10.13.0", "prettier": "^3.9.10", "tsx": "^4.23.15", "typescript": "^5.9.3", - "typescript-eslint": "^8.71.2" + "typescript-eslint": "^8.71.2", + "vitest": "^5.0.5" } } diff --git a/backend/src/backfillArtworkDates.ts b/backend/src/backfillArtworkDates.ts new file mode 100644 index 0000000..c1388c6 --- /dev/null +++ b/backend/src/backfillArtworkDates.ts @@ -0,0 +1,81 @@ +import { MongoClient } from "mongodb"; +import { normalizeArtworkDates } from "./normalizeArtworkDates.js"; + +const mongoUri = process.env.MONGODB_URI; + +if (!mongoUri) { + throw new Error("MONGODB_URI mangler"); +} + +const client = new MongoClient(mongoUri); +const applyUpdates = process.argv.includes("--write"); + +async function backfillArtworkDates() { + try { + await client.connect(); + + console.log( + applyUpdates + ? "Write mode: artwork dates will be updated." + : "Dry run: no database changes will be made.", + ); + + const database = client.db("artex"); + const collection = database.collection("met_artworks"); + + const filter = { + $or: [ + { date_start: { $exists: false } }, + { date_end: { $exists: false } }, + ], + }; + + const totalCount = await collection.countDocuments(filter); + + console.log(`Found ${totalCount} artworks with missing date fields.`); + + const artworks = collection + .find(filter, { projection: { id: 1 } }) + .sort({ id: 1 }); + + for await (const artwork of artworks) { + if (!Number.isInteger(artwork.id)) { + throw new Error(`Artwork ${artwork._id} has an invalid ID`); + } + + await new Promise((resolve) => setTimeout(resolve, 1500)); + + const response = await fetch( + `https://collectionapi.metmuseum.org/public/collection/v1/objects/${artwork.id}`, + { signal: AbortSignal.timeout(15000) }, + ); + + if (!response.ok) { + throw new Error( + `Could not fetch artwork ${artwork.id}: HTTP ${response.status}`, + ); + } + + const metArtwork = await response.json(); + + if (metArtwork.objectID !== artwork.id) { + throw new Error(`Unexpected object ID for artwork ${artwork.id}`); + } + + const dates = normalizeArtworkDates(metArtwork); + + if (applyUpdates) { + await collection.updateOne({ _id: artwork._id }, { $set: dates }); + } + + console.log(artwork.id, metArtwork.objectDate, dates); + } + } finally { + await client.close(); + } +} + +backfillArtworkDates().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/backend/src/graphql/resolvers/artworkResolvers.ts b/backend/src/graphql/resolvers/artworkResolvers.ts new file mode 100644 index 0000000..e92e553 --- /dev/null +++ b/backend/src/graphql/resolvers/artworkResolvers.ts @@ -0,0 +1,248 @@ +import type { Db } from "mongodb"; + +type ArtworkSort = + | "TITLE_ASC" + | "TITLE_DESC" + | "ARTIST_ASC" + | "ARTIST_DESC" + | "YEAR_ASC" + | "YEAR_DESC" + | null; + +export function createArtworkResolvers(db: Db) { + return { + artists: async ( + _: unknown, + { + limit, + offset, + search, + }: { + limit: number; + offset: number; + search?: string | null; + }, + ) => { + if (limit < 1 || limit > 100 || offset < 0) { + throw new Error("Invalid pagination parameters"); + } + + const collection = db.collection("met_artworks"); + const searchTerm = search?.trim() ?? ""; + + const escapedSearchTerm = searchTerm.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + + const artists = await collection + .aggregate<{ name: string }>([ + { + $match: { + artist_display: { $type: "string" }, + }, + }, + { + $match: { + artist_display: { + $regex: escapedSearchTerm, + $options: "i", + }, + }, + }, + { + $group: { + _id: "$artist_display", + }, + }, + { + $match: { + _id: { $regex: "\\S" }, + }, + }, + { $sort: { _id: 1 } }, + { $skip: offset }, + { $limit: limit }, + { + $project: { + _id: 0, + name: "$_id", + }, + }, + ]) + .toArray(); + + return artists.map((artist) => artist.name); + }, + + artwork: async (_: unknown, { id }: { id: number }) => { + const collection = db.collection("met_artworks"); + + return await collection.findOne({ id }); + }, + + artworks: async ( + _: unknown, + { + limit, + offset, + search, + artist, + sort, + }: { + limit: number; + offset: number; + search?: string | null; + artist?: string | null; + sort?: ArtworkSort; + }, + ) => { + if (limit < 1 || limit > 100 || offset < 0) { + throw new Error("Invalid pagination parameters"); + } + + const collection = db.collection("met_artworks"); + const searchTerm = search?.trim() ?? ""; + + const escapedSearchTerm = searchTerm.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); + + const searchFilter = searchTerm + ? { + $or: [ + { + title: { + $regex: escapedSearchTerm, + $options: "i", + }, + }, + { + artist_display: { + $regex: escapedSearchTerm, + $options: "i", + }, + }, + ], + } + : {}; + + const filter = { + ...searchFilter, + ...(artist ? { artist_display: artist } : {}), + }; + + let sortOrder: Record; + + switch (sort) { + case "TITLE_ASC": + sortOrder = { titleSort: 1, id: 1 }; + break; + + case "TITLE_DESC": + sortOrder = { titleSort: -1, id: 1 }; + break; + + case "ARTIST_ASC": + sortOrder = { + artistUnknown: 1, + artist_display: 1, + id: 1, + }; + break; + + case "ARTIST_DESC": + sortOrder = { + artistUnknown: 1, + artist_display: -1, + id: 1, + }; + break; + + case "YEAR_ASC": + sortOrder = { dateUnknown: 1, date_start: 1, id: 1 }; + break; + + case "YEAR_DESC": + sortOrder = { dateUnknown: 1, date_start: -1, id: 1 }; + break; + + default: + sortOrder = { id: 1 }; + } + + const [artworks, totalCount] = await Promise.all([ + collection + .aggregate( + [ + { $match: filter }, + { + $set: { + dateUnknown: { + $cond: [{ $isNumber: "$date_start" }, 0, 1], + }, + artistUnknown: { + $cond: [ + { + $in: [ + { + $toLower: { + $trim: { + input: { + $ifNull: ["$artist_display", ""], + }, + }, + }, + }, + ["", "unknown"], + ], + }, + 1, + 0, + ], + }, + titleSort: { + $reduce: { + input: ['"', "“", "”"], + initialValue: "$title", + in: { + $replaceAll: { + input: "$$value", + find: "$$this", + replacement: "", + }, + }, + }, + }, + }, + }, + { $sort: sortOrder }, + { $skip: offset }, + { $limit: limit }, + { $unset: ["titleSort", "artistUnknown", "dateUnknown"] }, + ], + { + collation: { + locale: "en", + strength: 1, + }, + }, + ) + .toArray(), + + collection.countDocuments(filter, { + collation: { + locale: "en", + strength: 1, + }, + }), + ]); + + return { + artworks, + totalCount, + }; + }, + }; +} diff --git a/backend/src/graphql/resolvers/galleryResolvers.ts b/backend/src/graphql/resolvers/galleryResolvers.ts new file mode 100644 index 0000000..dfe4e16 --- /dev/null +++ b/backend/src/graphql/resolvers/galleryResolvers.ts @@ -0,0 +1,121 @@ +import type { Db } from "mongodb"; +import { ObjectId } from "mongodb"; +import { GraphQLError } from "graphql"; + +type Context = { + userId: string | null; +}; + +export function createGalleryResolvers(db: Db) { + return { + myGalleries: async (_parent: unknown, _args: unknown, context: Context) => { + if (!context.userId || !ObjectId.isValid(context.userId)) { + throw new GraphQLError("You must be logged in"); + } + + const galleries = await db + .collection("galleries") + .find({ userId: new ObjectId(context.userId) }) + .toArray(); + + return galleries.map((gallery) => ({ + id: gallery._id.toString(), + userId: gallery.userId.toString(), + title: gallery.title, + artworkIds: gallery.artworkIds, + })); + }, + + createGallery: async ( + _parent: unknown, + args: { title: string }, + context: Context, + ) => { + //checks that the user is logged in + if (!context.userId || !ObjectId.isValid(context.userId)) { + throw new GraphQLError("You must be logged in to create a gallery"); + } + + // checks that the gallery have a name + const title = args.title.trim(); + + if (title.length === 0) { + throw new GraphQLError("Gallery title cannot be empty"); + } + + // saves the gallery in mongo db + const result = await db.collection("galleries").insertOne({ + userId: new ObjectId(context.userId), + title, + artworkIds: [], + createdAt: new Date(), + }); + + // returns the new gallery + return { + id: result.insertedId.toString(), + userId: context.userId, + title, + artworkIds: [], + }; + }, + + addArtworkToGallery: async ( + _parent: unknown, + args: { galleryId: string; artworkId: number }, + context: Context, + ) => { + // Check that the user is logged in + if (!context.userId || !ObjectId.isValid(context.userId)) { + throw new GraphQLError("You must be logged in"); + } + + // Validate the gallery ID before converting it to ObjectId + if (!ObjectId.isValid(args.galleryId)) { + throw new GraphQLError("Invalid gallery"); + } + + const galleryId = new ObjectId(args.galleryId); + const userId = new ObjectId(context.userId); + + // Make sure the gallery exists and belongs to the logged-in user + const gallery = await db.collection("galleries").findOne({ + _id: galleryId, + userId, + }); + + if (!gallery) { + throw new GraphQLError("Gallery not found"); + } + + // Add the artwork ID without creating duplicates + await db.collection("galleries").updateOne( + { + _id: galleryId, + userId, + }, + { + $addToSet: { + artworkIds: args.artworkId, + }, + }, + ); + + // Fetch the updated gallery so it can be returned to the client + const updatedGallery = await db.collection("galleries").findOne({ + _id: galleryId, + }); + + if (!updatedGallery) { + throw new GraphQLError("Gallery not found"); + } + + return { + id: updatedGallery._id.toString(), + userId: updatedGallery.userId.toString(), + title: updatedGallery.title, + artworkIds: updatedGallery.artworkIds, + }; + }, + }; +} diff --git a/backend/src/graphql/resolvers/index.ts b/backend/src/graphql/resolvers/index.ts new file mode 100644 index 0000000..861f644 --- /dev/null +++ b/backend/src/graphql/resolvers/index.ts @@ -0,0 +1,27 @@ +import type { Db } from "mongodb"; +import { createUserResolvers } from "./userResolvers.js"; +import { createArtworkResolvers } from "./artworkResolvers.js"; +import { createGalleryResolvers } from "./galleryResolvers.js"; + +export function createResolvers(db: Db) { + const galleryResolvers = createGalleryResolvers(db); + const userResolvers = createUserResolvers(db); + + return { + Query: { + hello: () => "Hello from Artex backend!", + ...createArtworkResolvers(db), + myGalleries: galleryResolvers.myGalleries, + me: userResolvers.me, + }, + + Mutation: { + login: userResolvers.login, + register: userResolvers.register, + updateProfileImage: userResolvers.updateProfileImage, + createGallery: galleryResolvers.createGallery, + addArtworkToGallery: galleryResolvers.addArtworkToGallery, + updateCoverImage: userResolvers.updateCoverImage, + }, + }; +} diff --git a/backend/src/graphql/resolvers/userResolvers.ts b/backend/src/graphql/resolvers/userResolvers.ts new file mode 100644 index 0000000..585d304 --- /dev/null +++ b/backend/src/graphql/resolvers/userResolvers.ts @@ -0,0 +1,207 @@ +import { ObjectId, type Db } from "mongodb"; +import { GraphQLError } from "graphql"; +import { hashPassword, verifyPassword } from "../../utils/password.js"; +import { createToken } from "../../utils/token.js"; + +export function createUserResolvers(db: Db) { + return { + me: async ( + _parent: unknown, + _args: unknown, + context: { userId: string | null }, + ) => { + if (!context.userId || !ObjectId.isValid(context.userId)) { + throw new GraphQLError("You must be logged in"); + } + + const user = await db.collection("users").findOne({ + _id: new ObjectId(context.userId), + }); + + if (!user) { + throw new GraphQLError("User not found"); + } + + return { + id: user._id.toString(), + username: user.username, + profileImage: user.profileImage ?? null, + coverImage: user.coverImage ?? null, + }; + }, + + //Cover Image + updateCoverImage: async ( + _parent: unknown, + args: { image: string }, + context: { userId: string | null }, + ) => { + if (!context.userId || !ObjectId.isValid(context.userId)) { + throw new GraphQLError("You must be logged in"); + } + + // Only accept JPEG, PNG and WebP + if ( + !/^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test( + args.image, + ) + ) { + throw new GraphQLError("Invalid image format"); + } + + // Limit image size to approximately 2 MB + if (Buffer.byteLength(args.image, "utf8") > 2_800_000) { + throw new GraphQLError("Image is too large"); + } + + const user = await db + .collection("users") + .findOneAndUpdate( + { _id: new ObjectId(context.userId) }, + { $set: { coverImage: args.image } }, + { returnDocument: "after" }, + ); + + if (!user) { + throw new GraphQLError("User not found"); + } + + return { + id: user._id.toString(), + username: user.username, + profileImage: user.profileImage ?? null, + coverImage: user.coverImage, + }; + }, + + //Profile image + updateProfileImage: async ( + _parent: unknown, + args: { image: string }, + context: { userId: string | null }, + ) => { + if (!context.userId) { + throw new GraphQLError("You must be logged in"); + } + + if (!ObjectId.isValid(context.userId)) { + throw new GraphQLError("Invalid user ID"); + } + + // Only accept JPEG, PNG and WebP images encoded as Base64 + if ( + !/^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/]+={0,2}$/.test( + args.image, + ) + ) { + throw new GraphQLError("Invalid image format"); + } + + // Limit the image to approximately 2 MB + if (Buffer.byteLength(args.image, "utf8") > 2_800_000) { + throw new GraphQLError("Image is too large"); + } + + const result = await db + .collection("users") + .findOneAndUpdate( + { _id: new ObjectId(context.userId) }, + { $set: { profileImage: args.image } }, + { returnDocument: "after" }, + ); + + if (!result) { + throw new GraphQLError("User not found"); + } + + return { + id: result._id.toString(), + username: result.username, + profileImage: result.profileImage, + }; + }, + + //Login + login: async ( + _parent: unknown, + args: { username: string; password: string }, + ) => { + const username = args.username.trim().toLowerCase(); + + const user = await db.collection("users").findOne({ username }); + + if (!user || typeof user.passwordHash !== "string") { + throw new GraphQLError("Incorrect username or password"); + } + + const passwordIsCorrect = await verifyPassword( + args.password, + user.passwordHash, + ); + + if (!passwordIsCorrect) { + throw new GraphQLError("Incorrect username or password"); + } + + const token = createToken(user._id.toString()); + + return { + user: { + id: user._id.toString(), + username: user.username, + }, + token, + }; + }, + + // Register + register: async ( + _parent: unknown, + args: { username: string; password: string }, + ) => { + const username = args.username.trim().toLowerCase(); + + if (username.length < 3 || username.length > 20) { + throw new GraphQLError("Username must be between 3 and 20 characters"); + } + + if (args.password.length < 8 || args.password.length > 100) { + throw new GraphQLError("Password must be between 8 and 100 characters"); + } + + const users = db.collection("users"); + + const existing = await users.findOne({ username }); + + if (existing) { + throw new GraphQLError("Username is already taken"); + } + + const passwordHash = await hashPassword(args.password); + + try { + const result = await users.insertOne({ + username, + passwordHash, + createdAt: new Date(), + }); + + return { + id: result.insertedId.toString(), + username, + }; + } catch (err: unknown) { + if ( + typeof err === "object" && + err !== null && + "code" in err && + err.code === 11000 + ) { + throw new GraphQLError("Username is already taken"); + } + + throw err; + } + }, + }; +} diff --git a/backend/src/graphql/typeDefs.ts b/backend/src/graphql/typeDefs.ts new file mode 100644 index 0000000..f3fa358 --- /dev/null +++ b/backend/src/graphql/typeDefs.ts @@ -0,0 +1,81 @@ +export const typeDefs = `#graphql + type Artwork { + id: Int! + title: String! + artist_display: String + date_display: String + date_start: Int + date_end: Int + image_url: String + medium: String + dimensions: String + country: String + city: String + culture: String + period: String + } + + type ArtworkPage { + artworks: [Artwork!]! + totalCount: Int! + } + + type User { + id: ID! + username: String! + profileImage: String + coverImage: String + } + + type AuthPayload { + user: User! + token: String! + } + + type Gallery { + id: ID! + userId: ID! + title: String! + artworkIds: [Int!]! + } + + enum ArtworkSort { + TITLE_ASC + TITLE_DESC + ARTIST_ASC + ARTIST_DESC + YEAR_ASC + YEAR_DESC + } + + type Query { + artwork(id: Int!): Artwork + hello: String! + me: User! + + artworks( + limit: Int! + offset: Int! + search: String + artist: String + sort: ArtworkSort + ): ArtworkPage! + + artists( + limit: Int! + offset: Int! + search: String + ): [String!]! + + myGalleries: [Gallery!]! + } + + type Mutation { + register(username: String!, password: String!): User! + login(username: String!, password: String!): AuthPayload! + createGallery(title: String!): Gallery! + addArtworkToGallery(galleryId: ID!, artworkId: Int!): Gallery! + updateProfileImage(image: String!): User! + updateCoverImage(image: String!): User! + } +`; diff --git a/backend/src/importMetArtworks.ts b/backend/src/importMetArtworks.ts index f694990..7c08d1b 100644 --- a/backend/src/importMetArtworks.ts +++ b/backend/src/importMetArtworks.ts @@ -1,4 +1,5 @@ import { MongoClient } from "mongodb"; +import { normalizeArtworkDates } from "./normalizeArtworkDates.js"; const mongoUri = process.env.MONGODB_URI; const IMPORT_LIMIT = 1000; @@ -16,8 +17,16 @@ type MetArtwork = { title: string; artistDisplayName: string; objectDate: string; + objectBeginDate: number; + objectEndDate: number; primaryImageSmall: string; isPublicDomain: boolean; + medium: string; + dimensions: string; + country: string; + city: string; + culture: string; + period: string; }; async function importArtworks() { @@ -78,7 +87,14 @@ async function importArtworks() { title: artwork.title, artist_display: artwork.artistDisplayName, date_display: artwork.objectDate, + ...normalizeArtworkDates(artwork), image_url: artwork.primaryImageSmall, + medium: artwork.medium, + dimensions: artwork.dimensions, + country: artwork.country, + city: artwork.city, + culture: artwork.culture, + period: artwork.period, }, }, { upsert: true }, diff --git a/backend/src/index.ts b/backend/src/index.ts deleted file mode 100644 index 67ba3bf..0000000 --- a/backend/src/index.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { ApolloServer } from "@apollo/server"; -import { startStandaloneServer } from "@apollo/server/standalone"; -import { connectToDatabase } from "./db.js"; -import { - randomBytes, - scrypt as scryptCallback, - timingSafeEqual, -} from "node:crypto"; -import { promisify } from "node:util"; -import { GraphQLError } from "graphql/error/index.js"; - -// Hash a password using scrypt -const scrypt = promisify(scryptCallback); - -async function hashPassword(password: string): Promise { - const salt = randomBytes(16); - const hash = (await scrypt(password, salt, 64)) as Buffer; - return `${salt.toString("hex")}:${hash.toString("hex")}`; -} - -async function verifyPassword( - password: string, - storedHash: string, -): Promise { - const [saltHex, hashHex] = storedHash.split(":"); - if (!saltHex || !hashHex) return false; - - const salt = Buffer.from(saltHex, "hex"); - const expectedHash = Buffer.from(hashHex, "hex"); - const actualHash = (await scrypt( - password, - salt, - expectedHash.length, - )) as Buffer; - - return ( - expectedHash.length === actualHash.length && - timingSafeEqual(expectedHash, actualHash) - ); -} - -// Connect to MongoDB -const db = await connectToDatabase(); - -// Prevent duplicate usernames at the database level -await db.collection("users").createIndex({ username: 1 }, { unique: true }); - -// Define the GraphQL schema -const typeDefs = `#graphql - type Artwork { - id: Int! - title: String! - artist_display: String - date_display: String - image_url: String - } - - type ArtworkPage { - artworks: [Artwork!]! - totalCount: Int! - } - - type User { - id: ID! - username: String! - } - - type Query { - hello: String! - artworks(limit: Int!, offset: Int!): ArtworkPage! - } - - type Mutation { - register(username: String!, password: String!): User! - login(username: String!, password: String!): User! - } -`; - -// Define how queries are resolved -const resolvers = { - Query: { - hello: () => "Hello from Artex backend!", - - artworks: async ( - _: unknown, - { limit, offset }: { limit: number; offset: number }, - ) => { - if (limit < 1 || limit > 100 || offset < 0) { - throw new Error("Invalid pagination parameters"); - } - const collection = db.collection("met_artworks"); - - const [artworks, totalCount] = await Promise.all([ - collection.find({}).sort({ id: 1 }).skip(offset).limit(limit).toArray(), - collection.countDocuments({}), - ]); - - return { - artworks, - totalCount, - }; - }, - }, - Mutation: { - login: async ( - _parent: unknown, - args: { username: string; password: string }, - ) => { - const username = args.username.trim().toLowerCase(); - const user = await db.collection("users").findOne({ username }); - - if (!user || typeof user.passwordHash !== "string") { - throw new GraphQLError("Incorrect username or password"); - } - - const passwordIsCorrect = await verifyPassword( - args.password, - user.passwordHash, - ); - if (!passwordIsCorrect) { - throw new GraphQLError("Incorrect username or password"); - } - - return { id: user._id.toString(), username: user.username }; - }, - register: async ( - _parent: unknown, - args: { username: string; password: string }, - ) => { - const username = args.username.trim().toLowerCase(); - - if (username.length < 3 || username.length > 20) { - throw new GraphQLError("Username must be between 3 and 20 characters"); - } - if (args.password.length < 8 || args.password.length > 100) { - throw new GraphQLError("Password must be between 8 and 100 characters"); - } - - const users = db.collection("users"); - - const existing = await users.findOne({ username }); - if (existing) { - throw new GraphQLError("Username is already taken"); - } - - const passwordHash = await hashPassword(args.password); - - try { - const result = await users.insertOne({ - username, - passwordHash, - createdAt: new Date(), - }); - return { id: result.insertedId.toString(), username }; - } catch (err: unknown) { - if ( - typeof err === "object" && - err !== null && - "code" in err && - err.code === 11000 - ) { - throw new GraphQLError("Username is already taken"); - } - - throw err; - } - }, - }, -}; - -// Create the Apollo Server -const server = new ApolloServer({ - typeDefs, - resolvers, -}); - -// Start the server -const { url } = await startStandaloneServer(server, { - listen: { port: 3001, host: "127.0.0.1" }, -}); - -console.log(`Server running at ${url}`); diff --git a/backend/src/normalizeArtworkDates.ts b/backend/src/normalizeArtworkDates.ts new file mode 100644 index 0000000..c294643 --- /dev/null +++ b/backend/src/normalizeArtworkDates.ts @@ -0,0 +1,24 @@ +type MetDates = { + objectDate?: string | null; + objectBeginDate?: number | null; + objectEndDate?: number | null; +}; + +export function normalizeArtworkDates(artwork: MetDates) { + const explicitlyUnknown = /\b(?:unknown|undated|not dated)\b/i.test( + artwork.objectDate ?? "", + ); + + function normalizeYear(value: number | null | undefined) { + return !explicitlyUnknown && + typeof value === "number" && + Number.isInteger(value) + ? value + : null; + } + + return { + date_start: normalizeYear(artwork.objectBeginDate), + date_end: normalizeYear(artwork.objectEndDate), + }; +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..795a656 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,49 @@ +import "dotenv/config"; +import { ApolloServer } from "@apollo/server"; +import { startStandaloneServer } from "@apollo/server/standalone"; +import { connectToDatabase } from "./db.js"; +import { typeDefs } from "./graphql/typeDefs.js"; +import { createResolvers } from "./graphql/resolvers/index.js"; +import { verifyToken } from "./utils/token.js"; + +// Connect to MongoDB +const db = await connectToDatabase(); + +// Prevent duplicate usernames at the database level +await db.collection("users").createIndex({ username: 1 }, { unique: true }); + +const resolvers = createResolvers(db); + +type Context = { + userId: string | null; +}; + +// Create the Apollo Server +const server = new ApolloServer({ + typeDefs, + resolvers, +}); + +// Start the server +const { url } = await startStandaloneServer(server, { + listen: { port: 3001, host: "127.0.0.1" }, + + context: async ({ req }): Promise => { + const authorization = req.headers.authorization; + + if (!authorization?.startsWith("Bearer ")) { + return { userId: null }; + } + + const token = authorization.slice(7); + + try { + const userId = verifyToken(token); + return { userId }; + } catch { + return { userId: null }; + } + }, +}); + +console.log(`Server running at ${url}`); diff --git a/backend/src/utils/password.ts b/backend/src/utils/password.ts new file mode 100644 index 0000000..bb028f5 --- /dev/null +++ b/backend/src/utils/password.ts @@ -0,0 +1,41 @@ +import { + randomBytes, + scrypt as scryptCallback, + timingSafeEqual, +} from "node:crypto"; + +import { promisify } from "node:util"; + +const scrypt = promisify(scryptCallback); + +// Hash a password using scrypt +export async function hashPassword(password: string): Promise { + const salt = randomBytes(16); + const hash = (await scrypt(password, salt, 64)) as Buffer; + + return `${salt.toString("hex")}:${hash.toString("hex")}`; +} + +// Verify a password against a stored hash +export async function verifyPassword( + password: string, + storedHash: string, +): Promise { + const [saltHex, hashHex] = storedHash.split(":"); + + if (!saltHex || !hashHex) return false; + + const salt = Buffer.from(saltHex, "hex"); + const expectedHash = Buffer.from(hashHex, "hex"); + + const actualHash = (await scrypt( + password, + salt, + expectedHash.length, + )) as Buffer; + + return ( + expectedHash.length === actualHash.length && + timingSafeEqual(expectedHash, actualHash) + ); +} diff --git a/backend/src/utils/token.ts b/backend/src/utils/token.ts new file mode 100644 index 0000000..46e604e --- /dev/null +++ b/backend/src/utils/token.ts @@ -0,0 +1,27 @@ +import jwt from "jsonwebtoken"; + +function getJwtSecret(): string { + const secret = process.env.JWT_SECRET; + + if (!secret) { + throw new Error("JWT_SECRET is not configured"); + } + + return secret; +} + +export function createToken(userId: string): string { + return jwt.sign({ userId }, getJwtSecret(), { + expiresIn: "7d", + }); +} + +export function verifyToken(token: string): string { + const decoded = jwt.verify(token, getJwtSecret()); + + if (typeof decoded === "string" || typeof decoded.userId !== "string") { + throw new Error("Invalid token"); + } + + return decoded.userId; +} diff --git a/backend/tests/artworks-filtering.test.ts b/backend/tests/artworks-filtering.test.ts new file mode 100644 index 0000000..66a077d --- /dev/null +++ b/backend/tests/artworks-filtering.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +async function fetchArtworks( + artist?: string, + search?: string, + limit = 10, + offset = 0, +) { + const response = await fetch("http://127.0.0.1:3001/", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: ` + query FilterArtworks( + $artist: String + $search: String + $limit: Int! + $offset: Int! + ) { + artworks( + artist: $artist + search: $search + limit: $limit + offset: $offset + ) { + totalCount + artworks { + id + title + artist_display + } + } + } + `, + variables: { artist, search, limit, offset }, + }), + }); + + expect(response.ok).toBe(true); + + const result = await response.json(); + + expect(result.errors).toBeUndefined(); + expect(result.data?.artworks).toBeDefined(); + + return result.data.artworks; +} + +async function fetchArtists(search?: string, limit = 5, offset = 0) { + const response = await fetch("http://127.0.0.1:3001/", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: ` + query ArtistOptions( + $search: String + $limit: Int! + $offset: Int! + ) { + artists(search: $search, limit: $limit, offset: $offset) + } + `, + variables: { search, limit, offset }, + }), + }); + + expect(response.ok).toBe(true); + + const result = await response.json(); + + expect(result.errors).toBeUndefined(); + expect(result.data?.artists).toBeDefined(); + + return result.data.artists as string[]; +} + +describe("Artwork filtering API", () => { + it("returns only artworks by the selected artist", async () => { + const artist = "John Singer Sargent"; + const result = await fetchArtworks(artist); + + expect(result.totalCount).toBeGreaterThan(0); + expect(result.artworks.length).toBeGreaterThan(0); + + for (const artwork of result.artworks) { + expect(artwork.artist_display).toBe(artist); + } + }); + + it("combines artist filtering with artwork search", async () => { + const result = await fetchArtworks("John Singer Sargent", "woman"); + + expect(result.totalCount).toBe(1); + expect(result.artworks).toEqual([ + { + id: 12072, + title: "Egyptian Woman", + artist_display: "John Singer Sargent", + }, + ]); + }); + + it("paginates filtered artworks while keeping the total count", async () => { + const artist = "John Singer Sargent"; + + const combined = await fetchArtworks(artist, undefined, 2, 0); + const firstPage = await fetchArtworks(artist, undefined, 1, 0); + const secondPage = await fetchArtworks(artist, undefined, 1, 1); + + expect(combined.totalCount).toBeGreaterThanOrEqual(2); + expect(firstPage.totalCount).toBe(combined.totalCount); + expect(secondPage.totalCount).toBe(combined.totalCount); + + expect(firstPage.artworks).toHaveLength(1); + expect(secondPage.artworks).toHaveLength(1); + + expect([...firstPage.artworks, ...secondPage.artworks]).toEqual( + combined.artworks, + ); + + expect(firstPage.artworks[0].id).not.toBe(secondPage.artworks[0].id); + }); + + it("returns no artworks when the selected artist does not exist", async () => { + const result = await fetchArtworks("nonexistent-artist-987654"); + + expect(result.totalCount).toBe(0); + expect(result.artworks).toEqual([]); + }); + + it("returns a limited list of unique, non-empty artist names", async () => { + const artists = await fetchArtists(undefined, 5, 0); + + expect(artists).toHaveLength(5); + expect(new Set(artists).size).toBe(artists.length); + + for (const artist of artists) { + expect(artist.trim().length).toBeGreaterThan(0); + } + }); + + it("searches artist options regardless of letter case", async () => { + const lowercase = await fetchArtists("sargent"); + const uppercase = await fetchArtists("SARGENT"); + + expect(lowercase).toContain("John Singer Sargent"); + expect(uppercase).toEqual(lowercase); + }); + + it("paginates artist options without overlap", async () => { + const combined = await fetchArtists(undefined, 10, 0); + const firstPage = await fetchArtists(undefined, 5, 0); + const secondPage = await fetchArtists(undefined, 5, 5); + + expect(firstPage).toHaveLength(5); + expect(secondPage).toHaveLength(5); + + expect([...firstPage, ...secondPage]).toEqual(combined); + expect(new Set(combined).size).toBe(10); + }); +}); diff --git a/backend/tests/artworks-search.test.ts b/backend/tests/artworks-search.test.ts new file mode 100644 index 0000000..e63e3f1 --- /dev/null +++ b/backend/tests/artworks-search.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +async function fetchArtworks(search?: string, limit = 3, offset = 0) { + const response = await fetch("http://localhost:3001/", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: ` + query SearchArtworks($search: String, $limit: Int!, $offset: Int!) { + artworks(search: $search, limit: $limit, offset: $offset) { + totalCount + artworks { + id + title + artist_display + } + } + } + `, + variables: { search, limit, offset }, + }), + }); + + expect(response.ok).toBe(true); + + const result = await response.json(); + + expect(result.errors).toBeUndefined(); + expect(result.data?.artworks).toBeDefined(); + + return result.data.artworks; +} + +describe("Artwork search API", () => { + it("returns an empty list and zero count when no artworks match", async () => { + const result = await fetchArtworks("zzzz-no-matching-artwork-987654"); + + expect(result.totalCount).toBe(0); + expect(result.artworks).toEqual([]); + }); + + it("returns the same results regardless of letter case", async () => { + const lowercase = await fetchArtworks("woman"); + const uppercase = await fetchArtworks("WOMAN"); + + expect(lowercase.totalCount).toBeGreaterThan(0); + expect(uppercase).toEqual(lowercase); + }); + + it("treats empty and whitespace-only searches as no search", async () => { + const withoutSearch = await fetchArtworks(); + const emptySearch = await fetchArtworks(""); + const whitespaceSearch = await fetchArtworks(" "); + + expect(withoutSearch.totalCount).toBeGreaterThan(0); + expect(emptySearch).toEqual(withoutSearch); + expect(whitespaceSearch).toEqual(withoutSearch); + }); + + it("paginates matching artworks and keeps the same total count", async () => { + const combined = await fetchArtworks("woman", 6, 0); + const firstPage = await fetchArtworks("woman", 3, 0); + const secondPage = await fetchArtworks("woman", 3, 3); + + expect(combined.totalCount).toBeGreaterThanOrEqual(6); + expect(firstPage.totalCount).toBe(combined.totalCount); + expect(secondPage.totalCount).toBe(combined.totalCount); + + expect(firstPage.artworks).toHaveLength(3); + expect(secondPage.artworks).toHaveLength(3); + + expect([...firstPage.artworks, ...secondPage.artworks]).toEqual( + combined.artworks, + ); + + const ids = combined.artworks.map((artwork: { id: number }) => artwork.id); + + expect(new Set(ids).size).toBe(6); + }); + + it("finds artworks by artist name", async () => { + const result = await fetchArtworks("sargent"); + + expect(result.totalCount).toBeGreaterThan(0); + + expect(result.artworks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 12072, + title: "Egyptian Woman", + artist_display: "John Singer Sargent", + }), + ]), + ); + }); +}); diff --git a/backend/tests/artworks-sorting.test.ts b/backend/tests/artworks-sorting.test.ts new file mode 100644 index 0000000..294c3fb --- /dev/null +++ b/backend/tests/artworks-sorting.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it } from "vitest"; + +type ArtworkSort = + | "TITLE_ASC" + | "TITLE_DESC" + | "ARTIST_ASC" + | "ARTIST_DESC" + | "YEAR_ASC" + | "YEAR_DESC"; + +type Artwork = { + id: number; + title: string; + artist_display: string | null; + date_start: number | null; + date_end: number | null; +}; + +type ArtworkPage = { + totalCount: number; + artworks: Artwork[]; +}; + +async function fetchArtworks( + sort?: ArtworkSort, + limit = 6, + offset = 0, + search?: string, + artist?: string, +): Promise { + const response = await fetch("http://127.0.0.1:3001/", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: ` + query SortedArtworks( + $sort: ArtworkSort + $limit: Int! + $offset: Int! + $search: String + $artist: String + ) { + artworks( + sort: $sort + limit: $limit + offset: $offset + search: $search + artist: $artist + ) { + totalCount + artworks { + id + title + artist_display + date_start + date_end + } + } + } + `, + variables: { sort, limit, offset, search, artist }, + }), + }); + + expect(response.ok).toBe(true); + + const result = await response.json(); + + expect(result.errors).toBeUndefined(); + expect(result.data?.artworks).toBeDefined(); + + return result.data.artworks; +} + +function isUnknownArtist(artist: string | null | undefined): boolean { + const name = artist?.trim().toLowerCase() ?? ""; + + return name === "" || name === "unknown"; +} + +describe("Artwork sorting API", () => { + it("sorts by ascending ID when no sorting option is provided", async () => { + const result = await fetchArtworks(); + + expect(result.artworks.length).toBeGreaterThan(1); + + const ids = result.artworks.map((artwork) => artwork.id); + const sortedIds = [...ids].sort((a, b) => a - b); + + expect(ids).toEqual(sortedIds); + }); + + it.each(["TITLE_ASC", "TITLE_DESC"])( + "sorts artwork titles correctly with %s", + async (sort) => { + const result = await fetchArtworks( + sort, + 10, + 0, + undefined, + "John Singer Sargent", + ); + const direction = sort === "TITLE_ASC" ? 1 : -1; + + expect(result.artworks.length).toBeGreaterThan(1); + + const collator = new Intl.Collator("en", { + sensitivity: "base", + }); + + const expected = [...result.artworks].sort((a, b) => { + const comparison = collator.compare(a.title, b.title); + + return comparison === 0 ? a.id - b.id : comparison * direction; + }); + + expect(result.artworks).toEqual(expected); + }, + ); + + it.each(["ARTIST_ASC", "ARTIST_DESC"])( + "sorts artists correctly with %s", + async (sort) => { + const result = await fetchArtworks(sort); + const direction = sort === "ARTIST_ASC" ? 1 : -1; + + expect(result.artworks.length).toBeGreaterThan(1); + + const collator = new Intl.Collator("en", { + sensitivity: "base", + }); + + const expected = [...result.artworks].sort((a, b) => { + const comparison = collator.compare( + a.artist_display ?? "", + b.artist_display ?? "", + ); + + return comparison === 0 ? a.id - b.id : comparison * direction; + }); + + expect(result.artworks).toEqual(expected); + }, + ); + + it.each([ + "TITLE_ASC", + "TITLE_DESC", + "ARTIST_ASC", + "ARTIST_DESC", + "YEAR_ASC", + "YEAR_DESC", + ])("keeps sorting consistent across pages with %s", async (sort) => { + const combined = await fetchArtworks(sort, 6, 0); + const firstPage = await fetchArtworks(sort, 3, 0); + const secondPage = await fetchArtworks(sort, 3, 3); + + expect(combined.artworks).toHaveLength(6); + expect(firstPage.artworks).toHaveLength(3); + expect(secondPage.artworks).toHaveLength(3); + + expect(firstPage.totalCount).toBe(combined.totalCount); + expect(secondPage.totalCount).toBe(combined.totalCount); + + expect([...firstPage.artworks, ...secondPage.artworks]).toEqual( + combined.artworks, + ); + + const ids = combined.artworks.map((artwork) => artwork.id); + + expect(new Set(ids).size).toBe(6); + }); + + it.each([ + "TITLE_ASC", + "TITLE_DESC", + "ARTIST_ASC", + "ARTIST_DESC", + "YEAR_ASC", + "YEAR_DESC", + ])("combines search and artist filtering with %s", async (sort) => { + const result = await fetchArtworks( + sort, + 10, + 0, + "woman", + "John Singer Sargent", + ); + + expect(result.totalCount).toBe(1); + expect(result.artworks).toEqual([ + { + id: 12072, + title: "Egyptian Woman", + artist_display: "John Singer Sargent", + date_start: 1890, + date_end: 1891, + }, + ]); + }); + + it("ignores curly double quotes when sorting titles", async () => { + const result = await fetchArtworks("TITLE_ASC", 100, 0, "painting"); + + const ids = result.artworks.map((artwork) => artwork.id); + + const artistsIndex = ids.indexOf(340799); + const bambooIndex = ids.indexOf(744431); + const benjaminIndex = ids.indexOf(436109); + + expect(artistsIndex).toBeGreaterThanOrEqual(0); + expect(bambooIndex).toBeGreaterThanOrEqual(0); + expect(benjaminIndex).toBeGreaterThanOrEqual(0); + + expect(artistsIndex).toBeLessThan(bambooIndex); + expect(bambooIndex).toBeLessThan(benjaminIndex); + + expect(result.artworks[bambooIndex].title).toBe( + "“Bamboo” section, from the Mustard Seed Garden Manual of Painting", + ); + }); + + it.each(["ARTIST_ASC", "ARTIST_DESC"])( + "places unknown artists last across pages with %s", + async (sort) => { + const pageSize = 100; + const firstPage = await fetchArtworks(sort, pageSize, 0); + const artworks = [...firstPage.artworks]; + + for ( + let offset = pageSize; + offset < firstPage.totalCount; + offset += pageSize + ) { + const page = await fetchArtworks(sort, pageSize, offset); + + expect(page.totalCount).toBe(firstPage.totalCount); + artworks.push(...page.artworks); + } + + expect(artworks).toHaveLength(firstPage.totalCount); + + const firstUnknownIndex = artworks.findIndex((artwork) => + isUnknownArtist(artwork.artist_display), + ); + + expect(firstUnknownIndex).toBeGreaterThan(0); + + for (const artwork of artworks.slice(firstUnknownIndex)) { + expect(isUnknownArtist(artwork.artist_display)).toBe(true); + } + + const ids = artworks.map((artwork) => artwork.id); + expect(new Set(ids).size).toBe(artworks.length); + }, + ); + + it.each(["YEAR_ASC", "YEAR_DESC"])( + "sorts artworks chronologically with %s", + async (sort) => { + const result = await fetchArtworks(sort, 10); + const direction = sort === "YEAR_ASC" ? 1 : -1; + + expect(result.artworks).toHaveLength(10); + + for (const artwork of result.artworks) { + expect(typeof artwork.date_start).toBe("number"); + } + + const expected = [...result.artworks].sort((a, b) => { + const yearComparison = a.date_start! - b.date_start!; + + return yearComparison === 0 ? a.id - b.id : yearComparison * direction; + }); + + expect(result.artworks).toEqual(expected); + }, + ); + + it.each(["YEAR_ASC", "YEAR_DESC"])( + "places unknown dates last across pages with %s", + async (sort) => { + const pageSize = 100; + const firstPage = await fetchArtworks(sort, pageSize, 0); + const artworks = [...firstPage.artworks]; + + for ( + let offset = pageSize; + offset < firstPage.totalCount; + offset += pageSize + ) { + const page = await fetchArtworks(sort, pageSize, offset); + + expect(page.totalCount).toBe(firstPage.totalCount); + artworks.push(...page.artworks); + } + + expect(artworks).toHaveLength(firstPage.totalCount); + + const firstUnknownIndex = artworks.findIndex( + (artwork) => artwork.date_start === null, + ); + + expect(firstUnknownIndex).toBeGreaterThan(0); + + for (const artwork of artworks.slice(firstUnknownIndex)) { + expect(artwork.date_start).toBeNull(); + } + + const ids = artworks.map((artwork) => artwork.id); + expect(new Set(ids).size).toBe(artworks.length); + }, + ); + + it("handles BCE years and preserves numeric date ranges", async () => { + const result = await fetchArtworks("YEAR_ASC", 5); + + expect(result.artworks[0]).toEqual( + expect.objectContaining({ + id: 544531, + date_start: -2575, + }), + ); + + expect(result.artworks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 544532, + date_start: -2051, + date_end: -2000, + }), + ]), + ); + }); +}); diff --git a/backend/tests/normalizeArtworkDates.test.ts b/backend/tests/normalizeArtworkDates.test.ts new file mode 100644 index 0000000..097fabe --- /dev/null +++ b/backend/tests/normalizeArtworkDates.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { normalizeArtworkDates } from "../src/normalizeArtworkDates.js"; + +describe("normalizeArtworkDates", () => { + it("preserves numeric dates when display text is empty", () => { + expect( + normalizeArtworkDates({ + objectDate: "", + objectBeginDate: 1200, + objectEndDate: 1279, + }), + ).toEqual({ date_start: 1200, date_end: 1279 }); + }); + + it("treats explicitly unknown dates as missing", () => { + expect( + normalizeArtworkDates({ + objectDate: "date unknown", + objectBeginDate: 1, + objectEndDate: 1929, + }), + ).toEqual({ date_start: null, date_end: null }); + }); + + it("preserves negative years and zero", () => { + expect( + normalizeArtworkDates({ + objectBeginDate: -100, + objectEndDate: 0, + }), + ).toEqual({ date_start: -100, date_end: 0 }); + }); + + it("returns null for missing or invalid years", () => { + expect(normalizeArtworkDates({})).toEqual({ + date_start: null, + date_end: null, + }); + + expect( + normalizeArtworkDates({ + objectBeginDate: Number.NaN, + objectEndDate: 1883.5, + }), + ).toEqual({ date_start: null, date_end: null }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e5b2394..1ccc63c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,8 +15,10 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-router-dom": "^7.18.4", + "react-select": "^5.10.2", "rxjs": "^7.8.2", - "tailwindcss": "^4.3.3" + "tailwindcss": "^4.3.3", + "zustand": "^5.0.16" }, "devDependencies": { "@babel/core": "^7.29.7", @@ -79,7 +81,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", @@ -135,7 +136,6 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.8", @@ -169,7 +169,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -179,7 +178,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -211,7 +209,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -221,7 +218,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -255,7 +251,6 @@ "version": "7.29.9", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.8" @@ -267,11 +262,19 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -286,7 +289,6 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", @@ -305,7 +307,6 @@ "version": "7.29.8", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.29.7", @@ -339,6 +340,126 @@ "keyv": "^5.6.0" } }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -467,6 +588,31 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@graphql-typed-document-node/core": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", @@ -612,18 +758,18 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.151.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", - "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", - "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", "cpu": [ "arm" ], @@ -637,9 +783,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", - "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", "cpu": [ "arm64" ], @@ -653,9 +799,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", - "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", "cpu": [ "arm64" ], @@ -669,9 +815,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", - "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", "cpu": [ "x64" ], @@ -685,9 +831,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", - "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", "cpu": [ "x64" ], @@ -701,9 +847,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", - "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", "cpu": [ "arm" ], @@ -717,9 +863,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", - "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", "cpu": [ "arm64" ], @@ -733,9 +879,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", - "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", "cpu": [ "arm64" ], @@ -749,9 +895,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", - "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", "cpu": [ "ppc64" ], @@ -765,9 +911,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", - "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", "cpu": [ "s390x" ], @@ -781,9 +927,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", - "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", "cpu": [ "x64" ], @@ -797,9 +943,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", - "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", "cpu": [ "x64" ], @@ -813,9 +959,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", - "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", "cpu": [ "arm64" ], @@ -829,9 +975,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", - "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", "cpu": [ "arm64" ], @@ -845,9 +991,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", - "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", "cpu": [ "x64" ], @@ -1479,11 +1625,16 @@ "undici-types": ">=7.24.0 <7.24.7" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1499,6 +1650,15 @@ "@types/react": "^19.3.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.70.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", @@ -1860,6 +2020,21 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/babel-plugin-react-compiler": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", @@ -1954,6 +2129,15 @@ "qified": "^0.10.1" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001812", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001812.tgz", @@ -1995,6 +2179,31 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2014,14 +2223,12 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2051,6 +2258,16 @@ "node": ">=8" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.439", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.439.tgz", @@ -2071,6 +2288,24 @@ "node": ">=10.13.0" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2085,7 +2320,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2327,6 +2561,12 @@ "flat-cache": "^6.1.23" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2377,6 +2617,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2456,6 +2705,18 @@ "node": ">=20" } }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -2473,6 +2734,15 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", @@ -2490,6 +2760,22 @@ "node": ">= 4" } }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2500,6 +2786,27 @@ "node": ">=0.8.19" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2543,14 +2850,12 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -2559,6 +2864,12 @@ "node": ">=6" } }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2859,6 +3170,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2875,6 +3192,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2909,6 +3238,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -2929,13 +3264,12 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -2967,6 +3301,15 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optimism": { "version": "0.18.1", "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", @@ -3029,6 +3372,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3049,6 +3422,21 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3068,9 +3456,9 @@ } }, "node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "funding": [ { "type": "opencollective", @@ -3087,9 +3475,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -3121,6 +3509,17 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3172,6 +3571,12 @@ "react": "^19.3.0" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/react-router": { "version": "7.18.4", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", @@ -3210,13 +3615,80 @@ "react-dom": ">=18" } }, + "node_modules/react-select": { + "version": "5.10.2", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz", + "integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/rolldown": { - "version": "1.2.11", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", - "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.151.0", + "@oxc-project/types": "=0.155.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3226,21 +3698,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.11", - "@rolldown/binding-android-arm64": "1.2.11", - "@rolldown/binding-darwin-arm64": "1.2.11", - "@rolldown/binding-darwin-x64": "1.2.11", - "@rolldown/binding-freebsd-x64": "1.2.11", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", - "@rolldown/binding-linux-arm64-gnu": "1.2.11", - "@rolldown/binding-linux-arm64-musl": "1.2.11", - "@rolldown/binding-linux-ppc64-gnu": "1.2.11", - "@rolldown/binding-linux-s390x-gnu": "1.2.11", - "@rolldown/binding-linux-x64-gnu": "1.2.11", - "@rolldown/binding-linux-x64-musl": "1.2.11", - "@rolldown/binding-openharmony-arm64": "1.2.11", - "@rolldown/binding-win32-arm64-msvc": "1.2.11", - "@rolldown/binding-win32-x64-msvc": "1.2.11" + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" } }, "node_modules/rxjs": { @@ -3297,6 +3769,15 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -3306,6 +3787,24 @@ "node": ">=0.10.0" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -3459,16 +3958,30 @@ "punycode": "^2.1.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", + "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz", - "integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.9", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -3485,7 +3998,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -3569,6 +4082,23 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3583,9 +4113,9 @@ } }, "node_modules/zod": { - "version": "4.6.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", - "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", "funding": { @@ -3604,6 +4134,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.16", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.16.tgz", + "integrity": "sha512-Uv7e327ihlCfayfja3hDc0M64Upev1LWBKpRobdK4NyovDDPeeX4imWXc/wzC1yc6fkYPoAY/ncKVPAs2jrtQw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 8b51af9..b6deb32 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,8 +17,10 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "react-router-dom": "^7.18.4", + "react-select": "^5.10.2", "rxjs": "^7.8.2", - "tailwindcss": "^4.3.3" + "tailwindcss": "^4.3.3", + "zustand": "^5.0.16" }, "devDependencies": { "@babel/core": "^7.29.7", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e1bcc5b..6f18051 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,38 @@ -import { Routes, Route } from "react-router-dom"; +import { Routes, Route, useLocation } from "react-router-dom"; import "./index.css"; import HomePage from "./pages/HomePage"; -import Login from "./pages/login"; -import CreateAccount from "./pages/CreateAccount"; +import Login from "./pages/LoginPage"; +import CreateAccount from "./pages/CreateAccountPage"; +import ProfilePage from "./pages/ProfilePage"; +import ArtworkDetailPage from "./pages/ArtworkDetailPage"; +import Footer from "./components/Footer"; +import ProtectedRoute from "./components/ProtectedRoute"; function App() { + // No footers for Login and CreateAccount Pages + const { pathname } = useLocation(); + const showFooter = pathname !== "/login" && pathname !== "/create-account"; + return ( - - } /> - } /> - } /> - +
+
+ + } /> + } /> + } /> + + + + } + /> + } /> + +
+ {showFooter &&
} +
); } diff --git a/frontend/src/components/AddToGalleryModal.tsx b/frontend/src/components/AddToGalleryModal.tsx new file mode 100644 index 0000000..de1cccf --- /dev/null +++ b/frontend/src/components/AddToGalleryModal.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from "react"; +import type { Gallery } from "../types/Gallery"; +import { useAuthStore } from "../store/authStore"; + +type AddToGalleryModalProps = { + artworkId: number; + onClose: () => void; +}; + +type GalleriesResponse = { + data?: { + myGalleries: Gallery[]; + }; + errors?: { message: string }[]; +}; + +type AddArtworkResponse = { + data?: { + addArtworkToGallery: Gallery; + }; + errors?: { message: string }[]; +}; + +export default function AddToGalleryModal({ + artworkId, + onClose, +}: AddToGalleryModalProps) { + const [galleries, setGalleries] = useState([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(""); + + const token = useAuthStore((state) => state.token); + + useEffect(() => { + async function fetchGalleries() { + const query = ` + query { + myGalleries { + id + userId + title + artworkIds + } + } + `; + + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token ?? ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (!response.ok) { + throw new Error("Could not fetch galleries"); + } + + const result = (await response.json()) as GalleriesResponse; + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + setGalleries(result.data?.myGalleries ?? []); + } catch (error) { + setMessage( + error instanceof Error ? error.message : "Something went wrong", + ); + } finally { + setLoading(false); + } + } + + void fetchGalleries(); + }, [token]); + + async function handleAddToGallery(galleryId: string) { + const mutation = ` + mutation AddArtworkToGallery($galleryId: ID!, $artworkId: Int!) { + addArtworkToGallery( + galleryId: $galleryId + artworkId: $artworkId + ) { + id + title + artworkIds + } + } + `; + + try { + setMessage(""); + + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token ?? ""}`, + }, + body: JSON.stringify({ + query: mutation, + variables: { + galleryId, + artworkId, + }, + }), + }); + + if (!response.ok) { + throw new Error("Could not add artwork to gallery"); + } + + const result = (await response.json()) as AddArtworkResponse; + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + onClose(); + } catch (error) { + setMessage( + error instanceof Error ? error.message : "Something went wrong", + ); + } + } + + return ( +
+
+
+ + + +
+ + {loading ? ( +

Loading galleries...

+ ) : galleries.length === 0 ? ( +

+ You have not created any galleries yet. +

+ ) : ( +
+ {galleries.map((gallery) => ( + + ))} +
+ )} + + {message && ( +

+ {message} +

+ )} +
+
+ ); +} diff --git a/frontend/src/components/ArtworkCard.tsx b/frontend/src/components/ArtworkCard.tsx index bf0aebd..a5f007e 100644 --- a/frontend/src/components/ArtworkCard.tsx +++ b/frontend/src/components/ArtworkCard.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { Link } from "react-router-dom"; import type { Artwork } from "../types/Artwork"; import "./ArtworkCard.css"; @@ -29,18 +30,20 @@ export default function ArtworkCard({ artwork }: ArtworkCardProps) { return (
- {artwork.image_url ? ( - - ) : ( -
Image unavailable
- )} + + {artwork.image_url ? ( + + ) : ( +
Image unavailable
+ )} -

{artwork.title}

-

{artist}

+

{artwork.title}

+

{artist}

+
); } diff --git a/frontend/src/components/CatalogueControls.tsx b/frontend/src/components/CatalogueControls.tsx new file mode 100644 index 0000000..039ef61 --- /dev/null +++ b/frontend/src/components/CatalogueControls.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState, type SubmitEvent } from "react"; +import { Search, SlidersHorizontal } from "lucide-react"; +import { useArtists } from "../hooks/useArtists"; +import Select from "react-select"; +import type { ArtworkSort } from "../types/ArtworkSort"; + +type CatalogueControlsProps = { + onSearch: (value: string) => void; + artist: string; + onArtistChange: (value: string) => void; + sort: ArtworkSort | undefined; + onSortChange: (value: ArtworkSort | undefined) => void; + totalCount: number; + onReset: () => void; +}; + +const sortOptions: { + value: ArtworkSort | ""; + label: string; +}[] = [ + { value: "", label: "Default order" }, + { value: "TITLE_ASC", label: "Title: A-Z" }, + { value: "TITLE_DESC", label: "Title: Z-A" }, + { value: "ARTIST_ASC", label: "Artist: A-Z" }, + { value: "ARTIST_DESC", label: "Artist: Z-A" }, + { value: "YEAR_ASC", label: "Year: oldest first" }, + { value: "YEAR_DESC", label: "Year: newest first" }, +]; + +export default function CatalogueControls({ + onSearch, + artist, + onArtistChange, + sort, + onSortChange, + totalCount, + onReset, +}: CatalogueControlsProps) { + const [searchInput, setSearchInput] = useState(""); + const [showFilters, setShowFilters] = useState(false); + const [artistSearchInput, setArtistSearchInput] = useState(""); + const [artistSearch, setArtistSearch] = useState(""); + + const { + artists, + loading: artistsLoading, + error: artistsError, + } = useArtists(artistSearch, showFilters); + + useEffect(() => { + if (!showFilters) return; + + const timer = window.setTimeout(() => { + setArtistSearch(artistSearchInput.trim()); + }, 300); + + return () => { + window.clearTimeout(timer); + }; + }, [artistSearchInput, showFilters]); + + function handleSubmit(event: SubmitEvent) { + event.preventDefault(); + onSearch(searchInput); + } + + function handleReset() { + setSearchInput(""); + setArtistSearchInput(""); + setArtistSearch(""); + onReset(); + } + + return ( +
+
+ + + setSearchInput(event.target.value)} + className="min-w-0 flex-1 rounded-l border border-gray-300 bg-gray-100 px-3 py-3 text-sm text-gray-900 placeholder:text-gray-500 focus:border-gray-600 focus:outline-none" + /> + + +
+
+ + +

+ {totalCount} {totalCount === 1 ? "result" : "results"} +

+
+
+ +
+
+ ); +} diff --git a/frontend/src/components/CreateGalleryModal.tsx b/frontend/src/components/CreateGalleryModal.tsx new file mode 100644 index 0000000..48c22fa --- /dev/null +++ b/frontend/src/components/CreateGalleryModal.tsx @@ -0,0 +1,120 @@ +import { LucideX } from "lucide-react"; +import { useState } from "react"; +import { useAuthStore } from "../store/authStore"; + +type CreateGalleryModalProps = { + onClose: () => void; + onCreated: () => void; +}; + +export default function CreateGalleryModal({ + onClose, + onCreated, +}: CreateGalleryModalProps) { + const [title, setTitle] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [error, setError] = useState(""); + + const token = useAuthStore((state) => state.token); + + async function handleCreateGallery() { + if (!title.trim() || isCreating) return; + + setIsCreating(true); + setError(""); + + const query = ` + mutation CreateGallery($title: String!) { + createGallery(title: $title) { + id + userId + title + artworkIds + } + } + `; + + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token ?? ""}`, + }, + body: JSON.stringify({ + query, + variables: { title: title.trim() }, + }), + }); + + if (!response.ok) { + throw new Error("Could not create gallery"); + } + + const result = await response.json(); + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + if (!result.data?.createGallery) { + throw new Error("Gallery was not created"); + } + onCreated(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setIsCreating(false); + } + } + + return ( +
+
+ + +

Create a new gallery

+
+
+
+
+
+
+
+ setTitle(e.target.value)} + placeholder="Enter gallery title" + className="w-full rounded-lg border mt-10 border-gray-300 p-3 outline-none focus:border-blue-600" + /> + + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx new file mode 100644 index 0000000..d91e785 --- /dev/null +++ b/frontend/src/components/Footer.tsx @@ -0,0 +1,7 @@ +export default function Footer() { + return ( +
+

© {new Date().getFullYear()} Artex. All rights reserved

+
+ ); +} diff --git a/frontend/src/components/GalleryCard.tsx b/frontend/src/components/GalleryCard.tsx new file mode 100644 index 0000000..a1ed92c --- /dev/null +++ b/frontend/src/components/GalleryCard.tsx @@ -0,0 +1,64 @@ +type GalleryCardProps = { + title: string; + artworkCount: number; + imageUrls: string[]; +}; + +export default function GalleryCard({ + title, + artworkCount, + imageUrls, +}: GalleryCardProps) { + return ( +
+ {imageUrls.length === 0 ? ( +
+ No image yet +
+ ) : imageUrls.length === 1 ? ( + + ) : imageUrls.length === 2 ? ( +
+ {imageUrls.map((imageUrl) => ( + + ))} +
+ ) : ( +
+ + + + + +
+ )} + +

{title}

+ +

+ {artworkCount} {artworkCount === 1 ? "artwork" : "artworks"} +

+
+ ); +} diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx index 8eecfb6..fd50640 100644 --- a/frontend/src/components/Hero.tsx +++ b/frontend/src/components/Hero.tsx @@ -2,13 +2,16 @@ import heroImageBack from "../assets/hero2.png"; import heroImageFront from "../assets/hero1.png"; import { CircleUserRound } from "lucide-react"; import { Link } from "react-router-dom"; +import { useAuthStore } from "../store/authStore"; function Hero() { + const isLoggedIn = useAuthStore((state) => Boolean(state.token)); + return (
diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..5e9515c --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,17 @@ +import { Navigate } from "react-router-dom"; +import type { ReactNode } from "react"; +import { useAuthStore } from "../store/authStore"; + +type ProtectedRouteProps = { + children: ReactNode; +}; + +export default function ProtectedRoute({ children }: ProtectedRouteProps) { + const token = useAuthStore((state) => state.token); + + if (!token) { + return ; + } + + return children; +} diff --git a/frontend/src/hooks/useArtists.ts b/frontend/src/hooks/useArtists.ts new file mode 100644 index 0000000..925f69e --- /dev/null +++ b/frontend/src/hooks/useArtists.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; + +export function useArtists(search = "", enabled = true) { + const [artists, setArtists] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + useEffect(() => { + if (!enabled) return; + + const controller = new AbortController(); + + async function fetchArtists() { + setLoading(true); + setError(""); + + try { + const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + signal: controller.signal, + body: JSON.stringify({ + query: ` + query ArtistOptions($search: String, $limit: Int!, $offset: Int!) { + artists(search: $search, limit: $limit, offset: $offset) + } + `, + variables: { + search: search.trim(), + limit: 50, + offset: 0, + }, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const result = await response.json(); + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + setArtists(result.data.artists); + } catch (err) { + if (controller.signal.aborted) return; + + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + } + + fetchArtists(); + + return () => controller.abort(); + }, [search, enabled]); + return { artists, loading, error }; +} diff --git a/frontend/src/hooks/useArtwork.ts b/frontend/src/hooks/useArtwork.ts new file mode 100644 index 0000000..fdccb1b --- /dev/null +++ b/frontend/src/hooks/useArtwork.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react"; +import type { Artwork } from "../types/Artwork"; + +export function useArtwork(id: number) { + const [artwork, setArtwork] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + + async function fetchArtwork() { + try { + setLoading(true); + setError(""); + + const response = await fetch(`${import.meta.env.BASE_URL}graphql`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + signal: controller.signal, + body: JSON.stringify({ + query: ` + query GetArtwork($id: Int!) { + artwork(id: $id) { + id + title + artist_display + date_display + image_url + medium + dimensions + country + city + culture + period + } + } + `, + variables: { id }, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const result = await response.json(); + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + setArtwork(result.data.artwork); + } catch (err) { + if (controller.signal.aborted) return; + + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + } + } + } + + fetchArtwork(); + + return () => controller.abort(); + }, [id]); + + return { artwork, loading, error }; +} diff --git a/frontend/src/hooks/useArtworks.ts b/frontend/src/hooks/useArtworks.ts index 6c7ff33..df5abed 100644 --- a/frontend/src/hooks/useArtworks.ts +++ b/frontend/src/hooks/useArtworks.ts @@ -1,7 +1,14 @@ import { useEffect, useState } from "react"; import type { Artwork } from "../types/Artwork"; +import type { ArtworkSort } from "../types/ArtworkSort"; -export function useArtworks(page: number, pageSize = 12) { +export function useArtworks( + page: number, + pageSize = 12, + search = "", + artist = "", + sort?: ArtworkSort, +) { const [artworks, setArtworks] = useState([]); const [totalCount, setTotalCount] = useState(0); const [error, setError] = useState(""); @@ -23,8 +30,20 @@ export function useArtworks(page: number, pageSize = 12) { signal: controller.signal, body: JSON.stringify({ query: ` - query GetArtworks($limit: Int!, $offset: Int!) { - artworks(limit: $limit, offset: $offset) { + query GetArtworks( + $limit: Int! + $offset: Int! + $search: String + $artist: String + $sort: ArtworkSort + ) { + artworks( + limit: $limit + offset: $offset + search: $search + artist: $artist + sort: $sort + ) { totalCount artworks { id @@ -38,6 +57,9 @@ export function useArtworks(page: number, pageSize = 12) { variables: { limit: pageSize, offset: (page - 1) * pageSize, + search, + artist, + sort, }, }), }); @@ -68,7 +90,7 @@ export function useArtworks(page: number, pageSize = 12) { fetchArtworks(); return () => controller.abort(); - }, [page, pageSize]); + }, [page, pageSize, search, artist, sort]); return { artworks, totalCount, loading, error }; } diff --git a/frontend/src/hooks/useGalleryCoverImages.ts b/frontend/src/hooks/useGalleryCoverImages.ts new file mode 100644 index 0000000..321d892 --- /dev/null +++ b/frontend/src/hooks/useGalleryCoverImages.ts @@ -0,0 +1,86 @@ +import { useEffect, useState } from "react"; +import type { Gallery } from "../types/Gallery"; + +type ArtworkResponse = { + data?: { + artwork: { + image_url?: string | null; + } | null; + }; + errors?: { message: string }[]; +}; + +export function useGalleryCoverImages(galleries: Gallery[]) { + const [coverImages, setCoverImages] = useState>({}); + + useEffect(() => { + async function fetchCoverImages() { + const images: Record = {}; + + await Promise.all( + galleries.map(async (gallery) => { + const artworkIds = gallery.artworkIds.slice(0, 3); + + if (artworkIds.length === 0) { + return; + } + + const galleryImages = await Promise.all( + artworkIds.map(async (artworkId) => { + const query = ` + query GetArtwork($id: Int!) { + artwork(id: $id) { + image_url + } + } + `; + + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query, + variables: { id: artworkId }, + }), + }); + + if (!response.ok) { + throw new Error("Could not fetch gallery cover image"); + } + + const result = (await response.json()) as ArtworkResponse; + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + return result.data?.artwork?.image_url ?? null; + } catch (error) { + console.error(`Could not fetch artwork ${artworkId}:`, error); + + return null; + } + }), + ); + + images[gallery.id] = galleryImages.filter( + (imageUrl): imageUrl is string => Boolean(imageUrl), + ); + }), + ); + + setCoverImages(images); + } + + if (galleries.length === 0) { + return; + } + + void fetchCoverImages(); + }, [galleries]); + + return coverImages; +} diff --git a/frontend/src/pages/ArtworkDetailPage.tsx b/frontend/src/pages/ArtworkDetailPage.tsx new file mode 100644 index 0000000..ab200d0 --- /dev/null +++ b/frontend/src/pages/ArtworkDetailPage.tsx @@ -0,0 +1,180 @@ +import { Link, useParams } from "react-router-dom"; +import { useArtwork } from "../hooks/useArtwork"; +import { useState } from "react"; +import AddToGalleryModal from "../components/AddToGalleryModal"; + +// ChatGPT was used to assist with the implementation of this page. +export default function ArtworkDetailPage() { + const { id } = useParams(); + const artworkId = Number(id); + + const { artwork, loading, error } = useArtwork(artworkId); + const [showGalleryModal, setShowGalleryModal] = useState(false); + + if (loading) { + return ( +

+ Loading artwork... +

+ ); + } + + if (error) { + return ( +

+ Error: {error} +

+ ); + } + + if (!artwork) { + return

Artwork not found.

; + } + + const artist = artwork.artist_display?.trim() || "Unknown artist"; + const place = [artwork.city, artwork.country].filter(Boolean).join(", "); + + return ( +
+ + ← Back + + +
+ {/* Artwork information */} +
+
+

+ {artwork.title} +

+ + +
+ +
+

{artist}

+ + {artwork.date_display && ( +

{artwork.date_display}

+ )} +
+ +
+

Details

+ +
+ {artwork.medium && ( +
+

Medium

+

{artwork.medium}

+
+ )} + + {place && ( +
+

Place

+

{place}

+
+ )} + + {artwork.culture && ( +
+

Culture

+

{artwork.culture}

+
+ )} + + {artwork.period && ( +
+

Period

+

{artwork.period}

+
+ )} + + {artwork.dimensions && ( +
+

Dimensions

+

{artwork.dimensions}

+
+ )} +
+
+
+ + {/* Artwork image */} +
+ {artwork.image_url ? ( + {`${artwork.title} + ) : ( +
+ Image unavailable +
+ )} +
+
+ + {/* Details */} +
+

Details

+ +
+ {artwork.medium && ( +
+

Medium

+

{artwork.medium}

+
+ )} + + {place && ( +
+

Place

+

{place}

+
+ )} + + {artwork.culture && ( +
+

Culture

+

{artwork.culture}

+
+ )} + + {artwork.period && ( +
+

Period

+

{artwork.period}

+
+ )} + + {artwork.dimensions && ( +
+

Dimensions

+

{artwork.dimensions}

+
+ )} +
+
+ + {showGalleryModal && ( + setShowGalleryModal(false)} + /> + )} +
+ ); +} diff --git a/frontend/src/pages/CreateAccount.tsx b/frontend/src/pages/CreateAccountPage.tsx similarity index 77% rename from frontend/src/pages/CreateAccount.tsx rename to frontend/src/pages/CreateAccountPage.tsx index d47a43a..f4387cb 100644 --- a/frontend/src/pages/CreateAccount.tsx +++ b/frontend/src/pages/CreateAccountPage.tsx @@ -1,12 +1,27 @@ import { useState, type SubmitEvent as ReactSubmitEvent } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; +import type { User } from "../types/User"; +import { useAuthStore } from "../store/authStore"; type RegisterResponse = { - data?: { register?: { id: string; username: string } }; + data?: { register?: User }; errors?: Array<{ message: string }>; }; +type LoginResponse = { + data?: { + login: { + user: User; + token: string; + }; + }; + errors?: { message: string }[]; +}; + export default function CreateAccount() { + const navigate = useNavigate(); + const setAuth = useAuthStore((state) => state.setAuth); + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [message, setMessage] = useState(""); @@ -45,8 +60,46 @@ export default function CreateAccount() { throw new Error("The server did not return the new account."); } - setMessage(`Account created for ${result.data.register.username}.`); - setPassword(""); + const loginQuery = ` + mutation Login($username: String!, $password: String!) { + login(username: $username, password: $password) { + user { + id + username + } + token + } + } + `; + + const loginResponse = await fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: loginQuery, + variables: { username, password }, + }), + }); + + if (!loginResponse.ok) { + throw new Error(`Server error (${loginResponse.status}).`); + } + + const loginResult = (await loginResponse.json()) as LoginResponse; + + if (loginResult.errors?.length) { + throw new Error(loginResult.errors[0].message); + } + + if (!loginResult.data?.login) { + throw new Error("Account was created, but login failed."); + } + + const { user, token } = loginResult.data.login; + + setAuth(user, token); + + navigate("/profile"); } catch (error) { setMessage( error instanceof Error ? error.message : "Something went wrong.", diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index acb6b66..39c3f93 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,36 +1,107 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import ArtworkGrid from "../components/ArtworkGrid"; import Hero from "../components/Hero"; import Pagination from "../components/Pagination"; import { useArtworks } from "../hooks/useArtworks"; +import CatalogueControls from "../components/CatalogueControls"; +import type { ArtworkSort } from "../types/ArtworkSort"; const PAGE_SIZE = 12; export default function HomePage() { const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [artist, setArtist] = useState(""); + const [sort, setSort] = useState(undefined); + const [showLoading, setShowLoading] = useState(false); - const { artworks, totalCount, loading, error } = useArtworks(page, PAGE_SIZE); + const { artworks, totalCount, loading, error } = useArtworks( + page, + PAGE_SIZE, + search, + artist, + sort, + ); + useEffect(() => { + if (!loading) return; + + const timer = window.setTimeout(() => { + setShowLoading(true); + }, 300); + + return () => { + window.clearTimeout(timer); + }; + }, [loading]); const totalPages = Math.ceil(totalCount / PAGE_SIZE); + function handleSearch(value: string) { + setShowLoading(false); + setSearch(value.trim()); + setPage(1); + } + + function handleArtistChange(value: string) { + setShowLoading(false); + setArtist(value); + setPage(1); + } + + function handleSortChange(value: ArtworkSort | undefined) { + setShowLoading(false); + setSort(value); + setPage(1); + } + + function handlePageChange(value: number) { + setShowLoading(false); + setPage(value); + } + + function handleReset() { + setShowLoading(false); + setSearch(""); + setArtist(""); + setSort(undefined); + setPage(1); + } return (
+ {error &&

Error: {error}

} - {loading &&

Loading artworks...

} +

+ {loading && showLoading ? "Loading artworks..." : ""} +

- + {!error && artworks.length > 0 && } + + {!loading && !error && artworks.length === 0 && ( +

+ No artworks match your search and filters. Try changing your + selections or use Clear all. +

+ )}
{!error && ( )}
diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/LoginPage.tsx similarity index 89% rename from frontend/src/pages/login.tsx rename to frontend/src/pages/LoginPage.tsx index 5779e7d..e33bb1f 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,12 +1,22 @@ -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { useState, type SubmitEvent as ReactSubmitEvent } from "react"; +import type { User } from "../types/User"; +import { useAuthStore } from "../store/authStore"; type LoginResponse = { - data?: { login?: { id: string; username: string } }; - errors?: Array<{ message: string }>; + data?: { + login: { + user: User; + token: string; + }; + }; + errors?: { message: string }[]; }; function Login() { + const navigate = useNavigate(); + const setAuth = useAuthStore((state) => state.setAuth); + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [message, setMessage] = useState(""); @@ -20,8 +30,11 @@ function Login() { const query = ` mutation Login($username: String!, $password: String!) { login(username: $username, password: $password) { - id - username + user { + id + username + } + token } } `; @@ -45,8 +58,11 @@ function Login() { throw new Error("The server did not return a user."); } - setMessage(`Welcome back, ${result.data.login.username}.`); - setPassword(""); + const { user, token } = result.data.login; + + setAuth(user, token); + + navigate("/profile"); } catch (error) { setMessage( error instanceof Error ? error.message : "Something went wrong.", diff --git a/frontend/src/pages/PagesPlaceHolder.tsx b/frontend/src/pages/PagesPlaceHolder.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..a140cc3 --- /dev/null +++ b/frontend/src/pages/ProfilePage.tsx @@ -0,0 +1,380 @@ +import GalleryCard from "../components/GalleryCard"; +import { LucidePlus } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { Gallery } from "../types/Gallery"; +import CreateGalleryModal from "../components/CreateGalleryModal"; +import { useGalleryCoverImages } from "../hooks/useGalleryCoverImages"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuthStore } from "../store/authStore"; + +const coverColors = [ + "#F3CA34", // Yellow + "#D69C9A", // Red + "#EBD5DC", // Purple + "#E79DA3", // Pink + "#D3CF5C", // Green + "#80AFBB", // Blue +]; + +export default function ProfilePage() { + const navigate = useNavigate(); + + const user = useAuthStore((state) => state.user); + const token = useAuthStore((state) => state.token); + const logout = useAuthStore((state) => state.logout); + + const [coverColor] = useState(() => { + const randomIndex = Math.floor(Math.random() * coverColors.length); + return coverColors[randomIndex]; + }); + const [coverImage, setCoverImage] = useState(null); + + const [showCreateGallery, setShowCreateGallery] = useState(false); + const [profileImage, setProfileImage] = useState(null); + + const [galleries, setGalleries] = useState([]); + const galleryCoverImages = useGalleryCoverImages(galleries); + const [isLoading, setIsLoading] = useState(true); // Galleries + const [isLoadingImages, setIsLoadingImages] = useState(true); + const [refreshGalleries, setRefreshGalleries] = useState(0); + + useEffect(() => { + async function fetchProfileImage() { + const query = ` + query { + me { + profileImage + coverImage + } + } + `; + + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token ?? ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (!response.ok) { + throw new Error("Could not fetch profile image"); + } + + const result = await response.json(); + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + setProfileImage(result.data.me.profileImage); + setCoverImage(result.data.me.coverImage); + } catch (error) { + console.error("Error fetching profile images:", error); + } finally { + setIsLoadingImages(false); + } + } + + void fetchProfileImage(); + }, [token]); + + useEffect(() => { + async function fetchGalleries() { + const query = ` + query { + myGalleries { + id + userId + title + artworkIds + } + } + `; + + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token ?? ""}`, + }, + body: JSON.stringify({ query }), + }); + + if (!response.ok) { + throw new Error("Could not fetch galleries"); + } + + const result = await response.json(); + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + setGalleries(result.data.myGalleries); + } catch (error) { + console.error("Error fetching galleries:", error); + } finally { + setIsLoading(false); + } + } + + void fetchGalleries(); + }, [refreshGalleries, token]); + + async function handleCoverImageChange( + event: React.ChangeEvent, + ) { + const file = event.target.files?.[0]; + if (!file) return; + + if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { + alert("Please select a JPEG, PNG or WebP image"); + return; + } + + if (file.size > 2_000_000) { + alert("Image must be smaller than 2 MB"); + return; + } + + const reader = new FileReader(); + + reader.onload = async () => { + const image = reader.result; + if (typeof image !== "string") return; + + const mutation = ` + mutation UpdateCoverImage($image: String!) { + updateCoverImage(image: $image) { + coverImage + } + } + `; + + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token ?? ""}`, + }, + body: JSON.stringify({ + query: mutation, + variables: { image }, + }), + }); + + if (!response.ok) { + throw new Error("Could not upload cover image"); + } + + const result = await response.json(); + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + setCoverImage(result.data.updateCoverImage.coverImage); + } catch (error) { + console.error("Error uploading cover image:", error); + alert("Could not save cover image"); + } + }; + + reader.readAsDataURL(file); + } + + async function handleProfileImageChange( + event: React.ChangeEvent, + ) { + const file = event.target.files?.[0]; + if (!file) return; + + if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) { + alert("Please select a JPEG, PNG or WebP image"); + return; + } + + if (file.size > 2_000_000) { + alert("Image must be smaller than 2 MB"); + return; + } + + const reader = new FileReader(); + + reader.onload = async () => { + const image = reader.result; + if (typeof image !== "string") return; + + const mutation = ` + mutation UpdateProfileImage($image: String!) { + updateProfileImage(image: $image) { + id + username + profileImage + } + } + `; + + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token ?? ""}`, + }, + body: JSON.stringify({ + query: mutation, + variables: { image }, + }), + }); + + if (!response.ok) { + throw new Error("Could not upload profile image"); + } + + const result = await response.json(); + + if (result.errors?.length) { + throw new Error(result.errors[0].message); + } + + setProfileImage(result.data.updateProfileImage.profileImage); + } catch (error) { + console.error("Error uploading profile image:", error); + alert("Could not save profile image"); + } + }; + + reader.readAsDataURL(file); + } + + function handleLogout() { + logout(); + navigate("/"); + } + + return ( +
+ + ← Back + + + + +
+ {isLoadingImages ? ( +
+ ) : ( + coverImage && ( + Cover + ) + )} + + + + +
+
+
+ ); +} diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts new file mode 100644 index 0000000..a243e2e --- /dev/null +++ b/frontend/src/store/authStore.ts @@ -0,0 +1,31 @@ +import { create } from "zustand"; +import { createJSONStorage, persist } from "zustand/middleware"; +import type { User } from "../types/User"; + +type AuthState = { + user: User | null; + token: string | null; + setAuth: (user: User, token: string) => void; + logout: () => void; +}; + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + token: null, + + setAuth: (user, token) => { + set({ user, token }); + }, + + logout: () => { + set({ user: null, token: null }); + }, + }), + { + name: "auth", + storage: createJSONStorage(() => sessionStorage), + }, + ), +); diff --git a/frontend/src/types/Artwork.ts b/frontend/src/types/Artwork.ts index 8b086ad..9fa92c3 100644 --- a/frontend/src/types/Artwork.ts +++ b/frontend/src/types/Artwork.ts @@ -1,6 +1,15 @@ export type Artwork = { id: number; title: string; - artist_display: string | null; - image_url: string | null; + artist_display?: string | null; + date_display?: string | null; + date_start?: number | null; + date_end?: number | null; + image_url?: string | null; + medium?: string | null; + dimensions?: string | null; + country?: string | null; + city?: string | null; + culture?: string | null; + period?: string | null; }; diff --git a/frontend/src/types/ArtworkSort.ts b/frontend/src/types/ArtworkSort.ts new file mode 100644 index 0000000..c0e199c --- /dev/null +++ b/frontend/src/types/ArtworkSort.ts @@ -0,0 +1,7 @@ +export type ArtworkSort = + | "TITLE_ASC" + | "TITLE_DESC" + | "ARTIST_ASC" + | "ARTIST_DESC" + | "YEAR_ASC" + | "YEAR_DESC"; diff --git a/frontend/src/types/Gallery.ts b/frontend/src/types/Gallery.ts new file mode 100644 index 0000000..97c0150 --- /dev/null +++ b/frontend/src/types/Gallery.ts @@ -0,0 +1,6 @@ +export type Gallery = { + id: string; + userId: string; + title: string; + artworkIds: number[]; +}; diff --git a/frontend/src/types/User.ts b/frontend/src/types/User.ts new file mode 100644 index 0000000..86a1ab4 --- /dev/null +++ b/frontend/src/types/User.ts @@ -0,0 +1,4 @@ +export type User = { + id: string; + username: string; +};