From a6d0ce0a5558dc6bfa93d972b1120d46fb927b20 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Tue, 20 Oct 2026 12:17:46 +0200 Subject: [PATCH 01/66] feat(backend): add artwork search with filtered pagination --- backend/src/index.ts | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 67ba3bf..1f9b5e6 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -67,7 +67,7 @@ const typeDefs = `#graphql type Query { hello: String! - artworks(limit: Int!, offset: Int!): ArtworkPage! + artworks(limit: Int!, offset: Int!, search: String): ArtworkPage! } type Mutation { @@ -83,16 +83,39 @@ const resolvers = { artworks: async ( _: unknown, - { limit, offset }: { limit: number; offset: number }, + { + 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 filter = searchTerm + ? { + $or: [ + { title: { $regex: escapedSearchTerm, $options: "i" } }, + { artist_display: { $regex: escapedSearchTerm, $options: "i" } }, + ], + } + : {}; const [artworks, totalCount] = await Promise.all([ - collection.find({}).sort({ id: 1 }).skip(offset).limit(limit).toArray(), - collection.countDocuments({}), + collection + .find(filter) + .sort({ id: 1 }) + .skip(offset) + .limit(limit) + .toArray(), + collection.countDocuments(filter), ]); return { From bdbbbede56561fe94f78e2247a71dc4f586995b3 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Tue, 20 Oct 2026 14:25:46 +0200 Subject: [PATCH 02/66] test: add artwork search API tests --- backend/package-lock.json | 1037 ++++++++++++++++++++++++- backend/package.json | 6 +- backend/tests/artworks-search.test.ts | 98 +++ 3 files changed, 1138 insertions(+), 3 deletions(-) create mode 100644 backend/tests/artworks-search.test.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 39e0833..e496f9c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,7 +21,8 @@ "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 +982,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 +1043,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 +1117,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", @@ -1359,6 +1695,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 +1785,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", @@ -1583,6 +1967,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 +2080,17 @@ "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/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1733,6 +2138,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 +2363,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 +2383,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", @@ -2411,6 +2843,279 @@ "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": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2461,6 +3166,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 +3310,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 +3387,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 +3492,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 +3522,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", @@ -2860,6 +3647,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", @@ -3037,6 +3859,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 +3888,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 +4136,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 +4366,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..8437c2d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,7 +8,8 @@ "build": "tsc", "start": "node dist/index.js", "lint": "eslint .", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "keywords": [], "author": "", @@ -27,6 +28,7 @@ "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/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", + }), + ]), + ); + }); +}); From 0ef3121d42aae04c101f67a0701378563023cc47 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Tue, 20 Oct 2026 15:05:59 +0200 Subject: [PATCH 03/66] feat: add artist filtering and paginated artist options --- backend/src/index.ts | 80 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 3 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 1f9b5e6..7fdbbc9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -67,7 +67,8 @@ const typeDefs = `#graphql type Query { hello: String! - artworks(limit: Int!, offset: Int!, search: String): ArtworkPage! + artworks(limit: Int!, offset: Int!, search: String, artist: String): ArtworkPage! + artists(limit: Int!, offset: Int!, search: String): [String!]! } type Mutation { @@ -81,13 +82,81 @@ const resolvers = { Query: { hello: () => "Hello from Artex backend!", + 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); + }, artworks: async ( _: unknown, { limit, offset, search, - }: { limit: number; offset: number; search?: string | null }, + artist, + }: { + limit: number; + offset: number; + search?: string | null; + artist?: string | null; + }, ) => { if (limit < 1 || limit > 100 || offset < 0) { throw new Error("Invalid pagination parameters"); @@ -99,7 +168,7 @@ const resolvers = { "\\$&", ); - const filter = searchTerm + const searchFilter = searchTerm ? { $or: [ { title: { $regex: escapedSearchTerm, $options: "i" } }, @@ -108,6 +177,11 @@ const resolvers = { } : {}; + const filter = { + ...searchFilter, + ...(artist ? { artist_display: artist } : {}), + }; + const [artworks, totalCount] = await Promise.all([ collection .find(filter) From 0e7afd4eb1652d563d3c6e37807187ca53661616 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Tue, 20 Oct 2026 15:14:16 +0200 Subject: [PATCH 04/66] test: add artist filtering and artist options API tests --- backend/tests/artworks-filtering.test.ts | 165 +++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 backend/tests/artworks-filtering.test.ts 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); + }); +}); From a50354ff842f7e076123116a5c1141942a8e2094 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Wed, 21 Oct 2026 17:07:48 +0200 Subject: [PATCH 05/66] feat: add artwork sorting with stable pagination --- backend/src/index.ts | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index 7fdbbc9..ac1dc09 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -65,9 +65,16 @@ const typeDefs = `#graphql username: String! } + enum ArtworkSort { + TITLE_ASC + TITLE_DESC + ARTIST_ASC + ARTIST_DESC + } + type Query { hello: String! - artworks(limit: Int!, offset: Int!, search: String, artist: String): ArtworkPage! + artworks(limit: Int!, offset: Int!, search: String, artist: String, sort: ArtworkSort): ArtworkPage! artists(limit: Int!, offset: Int!, search: String): [String!]! } @@ -151,11 +158,13 @@ const resolvers = { offset, search, artist, + sort, }: { limit: number; offset: number; search?: string | null; artist?: string | null; + sort?: "TITLE_ASC" | "TITLE_DESC" | "ARTIST_ASC" | "ARTIST_DESC" | null; }, ) => { if (limit < 1 || limit > 100 || offset < 0) { @@ -182,14 +191,36 @@ const resolvers = { ...(artist ? { artist_display: artist } : {}), }; + let sortOrder: Record; + + switch (sort) { + case "TITLE_ASC": + sortOrder = { title: 1, id: 1 }; + break; + case "TITLE_DESC": + sortOrder = { title: -1, id: 1 }; + break; + case "ARTIST_ASC": + sortOrder = { artist_display: 1, id: 1 }; + break; + case "ARTIST_DESC": + sortOrder = { artist_display: -1, id: 1 }; + break; + default: + sortOrder = { id: 1 }; + } + const [artworks, totalCount] = await Promise.all([ collection .find(filter) - .sort({ id: 1 }) + .collation({ locale: "en", strength: 1 }) + .sort(sortOrder) .skip(offset) .limit(limit) .toArray(), - collection.countDocuments(filter), + collection.countDocuments(filter, { + collation: { locale: "en", strength: 1 }, + }), ]); return { From 7d50429c0ad0aa564d3d73ef43195512b68ad957 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Wed, 21 Oct 2026 17:34:41 +0200 Subject: [PATCH 06/66] test: add artwork sorting API tests --- backend/tests/artworks-sorting.test.ts | 181 +++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 backend/tests/artworks-sorting.test.ts diff --git a/backend/tests/artworks-sorting.test.ts b/backend/tests/artworks-sorting.test.ts new file mode 100644 index 0000000..02f1982 --- /dev/null +++ b/backend/tests/artworks-sorting.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +type ArtworkSort = "TITLE_ASC" | "TITLE_DESC" | "ARTIST_ASC" | "ARTIST_DESC"; + +type Artwork = { + id: number; + title: string; + artist_display: string | 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 + } + } + } + `, + 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; +} + +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", + ])("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", + ])("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", + }, + ]); + }); +}); From eab7b29f2668227b6a9d7708e9c3bf90d61646d5 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Thu, 22 Oct 2026 10:04:39 +0200 Subject: [PATCH 07/66] fix: ignore double quotes in title sorting --- backend/src/index.ts | 38 +++++++++++++++++++++----- backend/tests/artworks-sorting.test.ts | 20 ++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index ac1dc09..c15979a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -195,10 +195,10 @@ const resolvers = { switch (sort) { case "TITLE_ASC": - sortOrder = { title: 1, id: 1 }; + sortOrder = { titleSort: 1, id: 1 }; break; case "TITLE_DESC": - sortOrder = { title: -1, id: 1 }; + sortOrder = { titleSort: -1, id: 1 }; break; case "ARTIST_ASC": sortOrder = { artist_display: 1, id: 1 }; @@ -212,11 +212,35 @@ const resolvers = { const [artworks, totalCount] = await Promise.all([ collection - .find(filter) - .collation({ locale: "en", strength: 1 }) - .sort(sortOrder) - .skip(offset) - .limit(limit) + .aggregate( + [ + { $match: filter }, + { + $set: { + titleSort: { + $reduce: { + input: ['"', "“", "”"], + initialValue: "$title", + in: { + $replaceAll: { + input: "$$value", + find: "$$this", + replacement: "", + }, + }, + }, + }, + }, + }, + { $sort: sortOrder }, + { $skip: offset }, + { $limit: limit }, + { $unset: "titleSort" }, + ], + { + collation: { locale: "en", strength: 1 }, + }, + ) .toArray(), collection.countDocuments(filter, { collation: { locale: "en", strength: 1 }, diff --git a/backend/tests/artworks-sorting.test.ts b/backend/tests/artworks-sorting.test.ts index 02f1982..10f3e00 100644 --- a/backend/tests/artworks-sorting.test.ts +++ b/backend/tests/artworks-sorting.test.ts @@ -178,4 +178,24 @@ describe("Artwork sorting API", () => { }, ]); }); + 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", + ); + }); }); From 2744bf3dc43c9c08e154a7023a9fc08dda60cd9a Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Thu, 22 Oct 2026 16:11:05 +0200 Subject: [PATCH 08/66] feat: support catalogue query options in useArtworks --- frontend/src/hooks/useArtworks.ts | 30 ++++++++++++++++++++++++++---- frontend/src/types/ArtworkSort.ts | 2 ++ 2 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 frontend/src/types/ArtworkSort.ts 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/types/ArtworkSort.ts b/frontend/src/types/ArtworkSort.ts new file mode 100644 index 0000000..ce8ce27 --- /dev/null +++ b/frontend/src/types/ArtworkSort.ts @@ -0,0 +1,2 @@ +export type ArtworkSort = + "TITLE_ASC" | "TITLE_DESC" | "ARTIST_ASC" | "ARTIST_DESC"; From 2b7c0b3c2c66a61860dd3d2789a4429d33a81b96 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Thu, 22 Oct 2026 16:30:29 +0200 Subject: [PATCH 09/66] feat: add catalogue search controls --- frontend/src/components/CatalogueControls.tsx | 49 +++++++++++++++++++ frontend/src/pages/HomePage.tsx | 28 +++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/CatalogueControls.tsx diff --git a/frontend/src/components/CatalogueControls.tsx b/frontend/src/components/CatalogueControls.tsx new file mode 100644 index 0000000..746ebd3 --- /dev/null +++ b/frontend/src/components/CatalogueControls.tsx @@ -0,0 +1,49 @@ +import { useState, type SubmitEvent } from "react"; +import { Search } from "lucide-react"; + +type CatalogueControlsProps = { + onSearch: (value: string) => void; +}; + +export default function CatalogueControls({ + onSearch, +}: CatalogueControlsProps) { + const [searchInput, setSearchInput] = useState(""); + + function handleSubmit(event: SubmitEvent) { + event.preventDefault(); + onSearch(searchInput); + } + + 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" + /> + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index acb6b66..afbede1 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,26 +1,48 @@ -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"; const PAGE_SIZE = 12; export default function HomePage() { const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [showLoading, setShowLoading] = useState(false); - const { artworks, totalCount, loading, error } = useArtworks(page, PAGE_SIZE); + const { artworks, totalCount, loading, error } = useArtworks(page, PAGE_SIZE, search); + 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); + } return (
+ {error &&

Error: {error}

} - {loading &&

Loading artworks...

} +

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

From a18bd59f1911039acc37f4f7b24678d7e985d184 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Thu, 22 Oct 2026 16:32:14 +0200 Subject: [PATCH 10/66] style: format catalogue search controls --- frontend/src/components/CatalogueControls.tsx | 8 ++------ frontend/src/pages/HomePage.tsx | 6 +++++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/CatalogueControls.tsx b/frontend/src/components/CatalogueControls.tsx index 746ebd3..1ea88bb 100644 --- a/frontend/src/components/CatalogueControls.tsx +++ b/frontend/src/components/CatalogueControls.tsx @@ -17,11 +17,7 @@ export default function CatalogueControls({ return (
-
+ @@ -46,4 +42,4 @@ export default function CatalogueControls({
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index afbede1..bbad222 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -12,7 +12,11 @@ export default function HomePage() { const [search, setSearch] = useState(""); const [showLoading, setShowLoading] = useState(false); - const { artworks, totalCount, loading, error } = useArtworks(page, PAGE_SIZE, search); + const { artworks, totalCount, loading, error } = useArtworks( + page, + PAGE_SIZE, + search, + ); useEffect(() => { if (!loading) return; From 94a7c82e911bac230c5eeab6bacb9197c3a15676 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Thu, 22 Oct 2026 16:59:32 +0200 Subject: [PATCH 11/66] feat: add searchable artist filter --- frontend/package-lock.json | 725 +++++++++++++++--- frontend/package.json | 1 + frontend/src/components/CatalogueControls.tsx | 86 ++- frontend/src/hooks/useArtists.ts | 64 ++ frontend/src/pages/HomePage.tsx | 14 +- 5 files changed, 790 insertions(+), 100 deletions(-) create mode 100644 frontend/src/hooks/useArtists.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e5b2394..ea8f2bd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "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" }, @@ -79,7 +80,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 +135,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 +168,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 +177,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 +208,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 +217,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 +250,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 +261,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 +288,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 +306,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 +339,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 +587,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 +757,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 +782,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 +798,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 +814,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 +830,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 +846,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 +862,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 +878,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 +894,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 +910,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 +926,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 +942,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 +958,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 +974,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 +990,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 +1624,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 +1649,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 +2019,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 +2128,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 +2178,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 +2222,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 +2257,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 +2287,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 +2319,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 +2560,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 +2616,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 +2704,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 +2733,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 +2759,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 +2785,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 +2849,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 +2863,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 +3169,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 +3191,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 +3237,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 +3263,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 +3300,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 +3371,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 +3421,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 +3455,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 +3474,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 +3508,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 +3570,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 +3614,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 +3697,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 +3768,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 +3786,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 +3957,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 +3997,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 +4081,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", diff --git a/frontend/package.json b/frontend/package.json index 8b51af9..59f9b7e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "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" }, diff --git a/frontend/src/components/CatalogueControls.tsx b/frontend/src/components/CatalogueControls.tsx index 1ea88bb..94ff517 100644 --- a/frontend/src/components/CatalogueControls.tsx +++ b/frontend/src/components/CatalogueControls.tsx @@ -1,14 +1,41 @@ -import { useState, type SubmitEvent } from "react"; +import { useEffect, useState, type SubmitEvent } from "react"; import { Search } from "lucide-react"; +import { useArtists } from "../hooks/useArtists"; +import Select from "react-select"; type CatalogueControlsProps = { onSearch: (value: string) => void; + artist: string; + onArtistChange: (value: string) => void; }; export default function CatalogueControls({ onSearch, + artist, + onArtistChange, }: 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(); @@ -40,6 +67,63 @@ export default function CatalogueControls({
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index d24833b..5110016 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -4,6 +4,7 @@ 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; @@ -11,6 +12,7 @@ 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( @@ -18,6 +20,7 @@ export default function HomePage() { PAGE_SIZE, search, artist, + sort, ); useEffect(() => { if (!loading) return; @@ -44,6 +47,17 @@ export default function HomePage() { setPage(1); } + function handleSortChange(value: ArtworkSort | undefined) { + setShowLoading(false); + setSort(value); + setPage(1); + } + + function handlePageChange(value: number) { + setShowLoading(false); + setPage(value); + } + return (
@@ -53,6 +67,8 @@ export default function HomePage() { onSearch={handleSearch} artist={artist} onArtistChange={handleArtistChange} + sort={sort} + onSortChange={handleSortChange} /> {error &&

Error: {error}

} @@ -68,7 +84,7 @@ export default function HomePage() { )}
From 419e0288013092ba7b163e4a0e0d80da0696a674 Mon Sep 17 00:00:00 2001 From: Karen Skauge Date: Thu, 22 Oct 2026 17:24:54 +0200 Subject: [PATCH 13/66] feat: add result count and reset catalogue selections --- frontend/src/components/CatalogueControls.tsx | 188 ++++++++++-------- frontend/src/pages/HomePage.tsx | 19 +- 2 files changed, 124 insertions(+), 83 deletions(-) diff --git a/frontend/src/components/CatalogueControls.tsx b/frontend/src/components/CatalogueControls.tsx index 27323d4..e94618d 100644 --- a/frontend/src/components/CatalogueControls.tsx +++ b/frontend/src/components/CatalogueControls.tsx @@ -10,6 +10,8 @@ type CatalogueControlsProps = { onArtistChange: (value: string) => void; sort: ArtworkSort | undefined; onSortChange: (value: ArtworkSort | undefined) => void; + totalCount: number; + onReset: () => void; }; export default function CatalogueControls({ @@ -18,6 +20,8 @@ export default function CatalogueControls({ onArtistChange, sort, onSortChange, + totalCount, + onReset, }: CatalogueControlsProps) { const [searchInput, setSearchInput] = useState(""); const [showFilters, setShowFilters] = useState(false); @@ -47,6 +51,13 @@ export default function CatalogueControls({ onSearch(searchInput); } + function handleReset() { + setSearchInput(""); + setArtistSearchInput(""); + setArtistSearch(""); + onReset(); + } + return (
@@ -72,90 +83,103 @@ export default function CatalogueControls({
{showFooter &&
} 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/hooks/useArtwork.ts b/frontend/src/hooks/useArtwork.ts new file mode 100644 index 0000000..d3bf2cc --- /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 }; +} \ No newline at end of file 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; }; From b457fd127187be8a3fc742d367c124aedc8ea302 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 17:33:19 +0100 Subject: [PATCH 43/66] feat: add artwork detail page --- frontend/src/pages/ArtworkDetailPage.tsx | 121 +++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 frontend/src/pages/ArtworkDetailPage.tsx diff --git a/frontend/src/pages/ArtworkDetailPage.tsx b/frontend/src/pages/ArtworkDetailPage.tsx new file mode 100644 index 0000000..03b7e81 --- /dev/null +++ b/frontend/src/pages/ArtworkDetailPage.tsx @@ -0,0 +1,121 @@ +import { useParams } from "react-router-dom"; +import { useArtwork } from "../hooks/useArtwork"; + +// ChatGBT was used to generate this page +export default function ArtworkDetailPage() { + const { id } = useParams(); + const artworkId = Number(id); + + const { artwork, loading, error } = useArtwork(artworkId); + + 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 ( +
+
+ {/* Artwork information */} +
+
+

+ {artwork.title} +

+ + +
+ +
+

{artist}

+ + {artwork.date_display && ( +

{artwork.date_display}

+ )} +
+
+ + {/* 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}

+
+ )} +
+
+
+ ); +} \ No newline at end of file From 6454179073c5a08d90583dc9632c129dd26536bb Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 17:37:30 +0100 Subject: [PATCH 44/66] docs: add AI usage comment --- frontend/src/pages/ArtworkDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/ArtworkDetailPage.tsx b/frontend/src/pages/ArtworkDetailPage.tsx index 03b7e81..d995d53 100644 --- a/frontend/src/pages/ArtworkDetailPage.tsx +++ b/frontend/src/pages/ArtworkDetailPage.tsx @@ -1,7 +1,7 @@ import { useParams } from "react-router-dom"; import { useArtwork } from "../hooks/useArtwork"; -// ChatGBT was used to generate this page +// ChatGPT was used to assist with the implementation of this page. export default function ArtworkDetailPage() { const { id } = useParams(); const artworkId = Number(id); From ee86c37622179449397c32ba72b16521df65ca43 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 18:25:41 +0100 Subject: [PATCH 45/66] feat: add back button to artwork detail page --- frontend/src/pages/ArtworkDetailPage.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/ArtworkDetailPage.tsx b/frontend/src/pages/ArtworkDetailPage.tsx index d995d53..6fa3607 100644 --- a/frontend/src/pages/ArtworkDetailPage.tsx +++ b/frontend/src/pages/ArtworkDetailPage.tsx @@ -1,4 +1,4 @@ -import { useParams } from "react-router-dom"; +import { Link, useParams } from "react-router-dom"; import { useArtwork } from "../hooks/useArtwork"; // ChatGPT was used to assist with the implementation of this page. @@ -33,9 +33,16 @@ export default function ArtworkDetailPage() { return (
+ + ← Back + +
{/* Artwork information */} -
+

{artwork.title} @@ -50,7 +57,7 @@ export default function ArtworkDetailPage() {

-
+

{artist}

{artwork.date_display && ( From 2db350780880a976cb01137c52d261d9316a0bdf Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 19:44:34 +0100 Subject: [PATCH 46/66] style: adjust artwork detail layout for large screens --- frontend/src/pages/ArtworkDetailPage.tsx | 47 ++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/ArtworkDetailPage.tsx b/frontend/src/pages/ArtworkDetailPage.tsx index 6fa3607..517b49b 100644 --- a/frontend/src/pages/ArtworkDetailPage.tsx +++ b/frontend/src/pages/ArtworkDetailPage.tsx @@ -52,8 +52,8 @@ export default function ArtworkDetailPage() { type="button" aria-label="Add artwork to gallery" className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-gray-300 text-2xl font-light text-gray-500 transition hover:border-gray-500 hover:text-black" - > - + + > + +
@@ -64,6 +64,47 @@ export default function ArtworkDetailPage() {

{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 */} @@ -83,7 +124,7 @@ export default function ArtworkDetailPage() {
{/* Details */} -
+

Details

From 39fd4219ad330787d911ef6b37526d958afe66e2 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 20:33:27 +0100 Subject: [PATCH 47/66] feat: add artworks to galleries --- .../src/graphql/resolvers/galleryResolvers.ts | 58 ++++++ backend/src/graphql/resolvers/index.ts | 1 + backend/src/graphql/typeDefs.ts | 1 + frontend/src/components/AddToGalleryModal.tsx | 183 ++++++++++++++++++ frontend/src/pages/ArtworkDetailPage.tsx | 11 ++ 5 files changed, 254 insertions(+) create mode 100644 frontend/src/components/AddToGalleryModal.tsx diff --git a/backend/src/graphql/resolvers/galleryResolvers.ts b/backend/src/graphql/resolvers/galleryResolvers.ts index 3664b25..dfe4e16 100644 --- a/backend/src/graphql/resolvers/galleryResolvers.ts +++ b/backend/src/graphql/resolvers/galleryResolvers.ts @@ -59,5 +59,63 @@ export function createGalleryResolvers(db: Db) { 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 index 07169e8..861f644 100644 --- a/backend/src/graphql/resolvers/index.ts +++ b/backend/src/graphql/resolvers/index.ts @@ -20,6 +20,7 @@ export function createResolvers(db: Db) { register: userResolvers.register, updateProfileImage: userResolvers.updateProfileImage, createGallery: galleryResolvers.createGallery, + addArtworkToGallery: galleryResolvers.addArtworkToGallery, updateCoverImage: userResolvers.updateCoverImage, }, }; diff --git a/backend/src/graphql/typeDefs.ts b/backend/src/graphql/typeDefs.ts index d71398f..f3fa358 100644 --- a/backend/src/graphql/typeDefs.ts +++ b/backend/src/graphql/typeDefs.ts @@ -74,6 +74,7 @@ export const typeDefs = `#graphql 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/frontend/src/components/AddToGalleryModal.tsx b/frontend/src/components/AddToGalleryModal.tsx new file mode 100644 index 0000000..dde823b --- /dev/null +++ b/frontend/src/components/AddToGalleryModal.tsx @@ -0,0 +1,183 @@ +import { useEffect, useState } from "react"; +import type { Gallery } from "../types/Gallery"; + +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(""); + + useEffect(() => { + async function fetchGalleries() { + const query = ` + query { + myGalleries { + id + userId + title + artworkIds + } + } + `; + + try { + const token = sessionStorage.getItem("token"); + + 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(); + }, []); + + async function handleAddToGallery(galleryId: string) { + const mutation = ` + mutation AddArtworkToGallery($galleryId: ID!, $artworkId: Int!) { + addArtworkToGallery( + galleryId: $galleryId + artworkId: $artworkId + ) { + id + title + artworkIds + } + } + `; + + try { + setMessage(""); + + const token = sessionStorage.getItem("token"); + + 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} +

+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/ArtworkDetailPage.tsx b/frontend/src/pages/ArtworkDetailPage.tsx index 517b49b..0cad80c 100644 --- a/frontend/src/pages/ArtworkDetailPage.tsx +++ b/frontend/src/pages/ArtworkDetailPage.tsx @@ -1,5 +1,7 @@ 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() { @@ -7,6 +9,7 @@ export default function ArtworkDetailPage() { const artworkId = Number(id); const { artwork, loading, error } = useArtwork(artworkId); + const [showGalleryModal, setShowGalleryModal] = useState(false); if (loading) { return ( @@ -51,6 +54,7 @@ export default function ArtworkDetailPage() {
+ + {showGalleryModal && ( + setShowGalleryModal(false)} + /> + )}
); } \ No newline at end of file From 6502d8f7ba26147e99e823a9bb40e44d7e52ae50 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 20:39:46 +0100 Subject: [PATCH 48/66] fix: show gallery cover image --- frontend/src/components/GalleryCard.tsx | 2 +- frontend/src/hooks/useGalleryCoverImages.ts | 85 +++++++++++++++++++++ frontend/src/pages/ProfilePage.tsx | 4 +- 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 frontend/src/hooks/useGalleryCoverImages.ts diff --git a/frontend/src/components/GalleryCard.tsx b/frontend/src/components/GalleryCard.tsx index 4e0af72..80c6eaf 100644 --- a/frontend/src/components/GalleryCard.tsx +++ b/frontend/src/components/GalleryCard.tsx @@ -11,7 +11,7 @@ export default function GalleryCard({ }: GalleryCardProps) { return (
- {artworkCount === 0 ? ( + {artworkCount === 0 || !imageUrl ? (
No image yet
diff --git a/frontend/src/hooks/useGalleryCoverImages.ts b/frontend/src/hooks/useGalleryCoverImages.ts new file mode 100644 index 0000000..6bfeb64 --- /dev/null +++ b/frontend/src/hooks/useGalleryCoverImages.ts @@ -0,0 +1,85 @@ +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 firstArtworkId = gallery.artworkIds[0]; + + if (!firstArtworkId) { + return; + } + + 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: firstArtworkId, + }, + }), + }); + + 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); + } + + const imageUrl = result.data?.artwork?.image_url; + + if (imageUrl) { + images[gallery.id] = imageUrl; + } + } catch (error) { + console.error( + `Could not fetch cover image for gallery ${gallery.id}:`, + error, + ); + } + }), + ); + + setCoverImages(images); + } + + if (galleries.length === 0) { + return; + } + + void fetchCoverImages(); + }, [galleries]); + + return coverImages; +} \ No newline at end of file diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 9292abc..44af4bb 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -4,6 +4,7 @@ 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"; const coverColors = [ "#F3CA34", // Yellow @@ -27,6 +28,7 @@ export default function ProfilePage() { const storedUser = sessionStorage.getItem("user"); const [galleries, setGalleries] = useState([]); + const galleryCoverImages = useGalleryCoverImages(galleries); const [isLoading, setIsLoading] = useState(true); // Galleries const [isLoadingImages, setIsLoadingImages] = useState(true); const [refreshGalleries, setRefreshGalleries] = useState(0); @@ -347,7 +349,7 @@ export default function ProfilePage() { key={gallery.id} title={gallery.title} artworkCount={gallery.artworkIds.length} - imageUrl="" + imageUrl={galleryCoverImages[gallery.id] ?? ""} /> ))}
From f379b025a652a72953e9fd1b2987c4f69a20d0b8 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 20:47:50 +0100 Subject: [PATCH 49/66] feat: support up to 3 images in gallery cover --- frontend/src/components/GalleryCard.tsx | 51 +++++++++-- frontend/src/hooks/useGalleryCoverImages.ts | 96 +++++++++++---------- frontend/src/pages/ProfilePage.tsx | 2 +- 3 files changed, 93 insertions(+), 56 deletions(-) diff --git a/frontend/src/components/GalleryCard.tsx b/frontend/src/components/GalleryCard.tsx index 80c6eaf..60ce85f 100644 --- a/frontend/src/components/GalleryCard.tsx +++ b/frontend/src/components/GalleryCard.tsx @@ -1,31 +1,64 @@ type GalleryCardProps = { title: string; artworkCount: number; - imageUrl: string; + imageUrls: string[]; }; export default function GalleryCard({ title, artworkCount, - imageUrl, + imageUrls, }: GalleryCardProps) { return (
- {artworkCount === 0 || !imageUrl ? ( -
+ {imageUrls.length === 0 ? ( +
No image yet
- ) : ( + ) : imageUrls.length === 1 ? ( {title} + ) : imageUrls.length === 2 ? ( +
+ {imageUrls.map((imageUrl) => ( + + ))} +
+ ) : ( +
+ + + + + +
)}

{title}

-

{artworkCount} artworks

+

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

); -} +} \ No newline at end of file diff --git a/frontend/src/hooks/useGalleryCoverImages.ts b/frontend/src/hooks/useGalleryCoverImages.ts index 6bfeb64..8b049e5 100644 --- a/frontend/src/hooks/useGalleryCoverImages.ts +++ b/frontend/src/hooks/useGalleryCoverImages.ts @@ -11,63 +11,67 @@ type ArtworkResponse = { }; export function useGalleryCoverImages(galleries: Gallery[]) { - const [coverImages, setCoverImages] = useState>({}); + const [coverImages, setCoverImages] = useState>({}); useEffect(() => { async function fetchCoverImages() { - const images: Record = {}; + const images: Record = {}; await Promise.all( galleries.map(async (gallery) => { - const firstArtworkId = gallery.artworkIds[0]; + const artworkIds = gallery.artworkIds.slice(0, 3); - if (!firstArtworkId) { + if (artworkIds.length === 0) { return; } - const query = ` - query GetArtwork($id: Int!) { - artwork(id: $id) { - image_url + 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; } - } - `; - - try { - const response = await fetch("/graphql", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - query, - variables: { - id: firstArtworkId, - }, - }), - }); - - 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); - } - - const imageUrl = result.data?.artwork?.image_url; - - if (imageUrl) { - images[gallery.id] = imageUrl; - } - } catch (error) { - console.error( - `Could not fetch cover image for gallery ${gallery.id}:`, - error, - ); - } + }), + ); + + images[gallery.id] = galleryImages.filter( + (imageUrl): imageUrl is string => Boolean(imageUrl), + ); }), ); diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 44af4bb..45db9e9 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -349,7 +349,7 @@ export default function ProfilePage() { key={gallery.id} title={gallery.title} artworkCount={gallery.artworkIds.length} - imageUrl={galleryCoverImages[gallery.id] ?? ""} + imageUrls={galleryCoverImages[gallery.id] ?? []} /> ))}
From 680298d63d28855d48b78f1efe3643bc24f1b069 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 20:57:00 +0100 Subject: [PATCH 50/66] style: adjust gallery cover image proportions --- frontend/src/components/GalleryCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/GalleryCard.tsx b/frontend/src/components/GalleryCard.tsx index 60ce85f..7f8b07e 100644 --- a/frontend/src/components/GalleryCard.tsx +++ b/frontend/src/components/GalleryCard.tsx @@ -33,7 +33,7 @@ export default function GalleryCard({ ))}
) : ( -
+
Date: Thu, 29 Oct 2026 21:07:01 +0100 Subject: [PATCH 51/66] style: add spacing above footer on profile page --- frontend/src/pages/ProfilePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 45db9e9..cb75cb2 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -337,7 +337,7 @@ export default function ProfilePage() { onCreated={() => setRefreshGalleries((prev) => prev + 1)} /> )} -
+
{isLoading ? (

Loading galleries...

) : galleries.length === 0 ? ( From 0f9d074d3a9bfc84ae72108a450954a14cf24e44 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 22:41:43 +0100 Subject: [PATCH 52/66] fix: protect profile page from unauthenticated users --- frontend/src/App.tsx | 10 +++++++++- frontend/src/components/ProtectedRoute.tsx | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ProtectedRoute.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5817264..6f18051 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ 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 @@ -19,7 +20,14 @@ function App() { } /> } /> } /> - } /> + + + + } + /> } /> diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..e01f97e --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,15 @@ +import { Navigate } from "react-router-dom"; + +type ProtectedRouteProps = { + children: React.ReactNode; +}; + +export default function ProtectedRoute({ children }: ProtectedRouteProps) { + const token = sessionStorage.getItem("token"); + + if (!token) { + return ; + } + + return children; +} \ No newline at end of file From 5ef80fa0b044c09a51d771d9559af018a1da0620 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 22:43:48 +0100 Subject: [PATCH 53/66] chore: fix navigation in hero --- frontend/src/components/Hero.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx index 8eecfb6..7422ed4 100644 --- a/frontend/src/components/Hero.tsx +++ b/frontend/src/components/Hero.tsx @@ -4,11 +4,13 @@ import { CircleUserRound } from "lucide-react"; import { Link } from "react-router-dom"; function Hero() { + const isLoggedIn = Boolean(sessionStorage.getItem("token")); + return (
From a13ae435d0506e3175a11599f6a6fc78ad93de91 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 22:46:09 +0100 Subject: [PATCH 54/66] chore: change navigation from login to profile page --- frontend/src/pages/LoginPage.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 247a4ef..dbd8203 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -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"; @@ -13,6 +13,8 @@ type LoginResponse = { }; function Login() { + const navigate = useNavigate(); + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [message, setMessage] = useState(""); @@ -59,8 +61,7 @@ function Login() { sessionStorage.setItem("user", JSON.stringify(user)); sessionStorage.setItem("token", token); - setMessage(`Welcome back, ${user.username}.`); - setPassword(""); + navigate("/profile"); } catch (error) { setMessage( error instanceof Error ? error.message : "Something went wrong.", From 45daa0b8a6b695a72556110fa9619091d48e28c1 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 22:50:04 +0100 Subject: [PATCH 55/66] chore: redirect new users to profile after registration --- frontend/src/pages/CreateAccountPage.tsx | 57 ++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/CreateAccountPage.tsx b/frontend/src/pages/CreateAccountPage.tsx index 71069b5..1bc91d7 100644 --- a/frontend/src/pages/CreateAccountPage.tsx +++ b/frontend/src/pages/CreateAccountPage.tsx @@ -1,5 +1,5 @@ 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"; type RegisterResponse = { @@ -7,7 +7,19 @@ type RegisterResponse = { errors?: Array<{ message: string }>; }; +type LoginResponse = { + data?: { + login: { + user: User; + token: string; + }; + }; + errors?: { message: string }[]; +}; + export default function CreateAccount() { + const navigate = useNavigate(); + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [message, setMessage] = useState(""); @@ -46,8 +58,47 @@ 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; + + sessionStorage.setItem("user", JSON.stringify(user)); + sessionStorage.setItem("token", token); + + navigate("/profile"); } catch (error) { setMessage( error instanceof Error ? error.message : "Something went wrong.", From d930fcb9e221b7ef614ad97c1d55215ac61446d5 Mon Sep 17 00:00:00 2001 From: mina Date: Thu, 29 Oct 2026 23:07:24 +0100 Subject: [PATCH 56/66] feat: add back and logout navigation to profile --- frontend/src/pages/ProfilePage.tsx | 43 +++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index cb75cb2..290db1b 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -5,6 +5,7 @@ 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"; const coverColors = [ "#F3CA34", // Yellow @@ -16,6 +17,8 @@ const coverColors = [ ]; export default function ProfilePage() { + const navigate = useNavigate(); + const [coverColor] = useState(() => { const randomIndex = Math.floor(Math.random() * coverColors.length); return coverColors[randomIndex]; @@ -258,8 +261,29 @@ export default function ProfilePage() { reader.readAsDataURL(file); } + function handleLogout() { + sessionStorage.removeItem("user"); + sessionStorage.removeItem("token"); + navigate("/"); + } + return (
+ + ← Back + + + +
+ + +
); -} \ No newline at end of file +} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index fd77cb5..d91e785 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -4,4 +4,4 @@ export default function Footer() {

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

); -} \ No newline at end of file +} diff --git a/frontend/src/components/GalleryCard.tsx b/frontend/src/components/GalleryCard.tsx index 7f8b07e..a1ed92c 100644 --- a/frontend/src/components/GalleryCard.tsx +++ b/frontend/src/components/GalleryCard.tsx @@ -61,4 +61,4 @@ export default function GalleryCard({

); -} \ No newline at end of file +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx index 6366ab1..5e9515c 100644 --- a/frontend/src/components/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute.tsx @@ -14,4 +14,4 @@ export default function ProtectedRoute({ children }: ProtectedRouteProps) { } return children; -} \ No newline at end of file +} diff --git a/frontend/src/hooks/useArtwork.ts b/frontend/src/hooks/useArtwork.ts index d3bf2cc..fdccb1b 100644 --- a/frontend/src/hooks/useArtwork.ts +++ b/frontend/src/hooks/useArtwork.ts @@ -70,4 +70,4 @@ export function useArtwork(id: number) { }, [id]); return { artwork, loading, error }; -} \ No newline at end of file +} diff --git a/frontend/src/hooks/useGalleryCoverImages.ts b/frontend/src/hooks/useGalleryCoverImages.ts index 8b049e5..321d892 100644 --- a/frontend/src/hooks/useGalleryCoverImages.ts +++ b/frontend/src/hooks/useGalleryCoverImages.ts @@ -59,10 +59,7 @@ export function useGalleryCoverImages(galleries: Gallery[]) { return result.data?.artwork?.image_url ?? null; } catch (error) { - console.error( - `Could not fetch artwork ${artworkId}:`, - error, - ); + console.error(`Could not fetch artwork ${artworkId}:`, error); return null; } @@ -86,4 +83,4 @@ export function useGalleryCoverImages(galleries: Gallery[]) { }, [galleries]); return coverImages; -} \ No newline at end of file +} diff --git a/frontend/src/pages/ArtworkDetailPage.tsx b/frontend/src/pages/ArtworkDetailPage.tsx index f6ff566..ab200d0 100644 --- a/frontend/src/pages/ArtworkDetailPage.tsx +++ b/frontend/src/pages/ArtworkDetailPage.tsx @@ -177,4 +177,4 @@ export default function ArtworkDetailPage() { )} ); -} \ No newline at end of file +} diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index efcd60f..a243e2e 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -28,4 +28,4 @@ export const useAuthStore = create()( storage: createJSONStorage(() => sessionStorage), }, ), -); \ No newline at end of file +);