diff --git a/backend/src/importMetArtworks.ts b/backend/src/importMetArtworks.ts new file mode 100644 index 0000000..b19b191 --- /dev/null +++ b/backend/src/importMetArtworks.ts @@ -0,0 +1,84 @@ +import { MongoClient } from "mongodb"; + +const mongoUri = process.env.MONGODB_URI; + +if (!mongoUri) { + throw new Error("MONGODB_URI mangler"); +} + +const client = new MongoClient(mongoUri); + +type MetArtwork = { + objectID: number; + title: string; + artistDisplayName: string; + objectDate: string; + primaryImageSmall: string; + isPublicDomain: boolean; +}; + +async function importArtworks() { + try { + await client.connect(); + + const database = client.db("artex"); + const collection = database.collection("met_artworks"); + + const searchResponse = await fetch( + "https://collectionapi.metmuseum.org/public/collection/v1.1/search?hasImages=true&q=painting&limit=100", + ); + + if (!searchResponse.ok) { + throw new Error(`Search failed: ${searchResponse.status}`); + } + + const searchData = (await searchResponse.json()) as { + objectIDs: number[] | null; + }; + + if (!searchData.objectIDs) { + throw new Error("Ingen kunstverk funnet"); + } + + let imported = 0; + + for (const id of searchData.objectIDs) { + if (imported >= 20) break; + + const response = await fetch( + `https://collectionapi.metmuseum.org/public/collection/v1/objects/${id}`, + ); + + if (!response.ok) continue; + + const artwork = (await response.json()) as MetArtwork; + + if (!artwork.isPublicDomain || !artwork.primaryImageSmall) { + continue; + } + + await collection.updateOne( + { id: artwork.objectID }, + { + $set: { + id: artwork.objectID, + title: artwork.title, + artist_display: artwork.artistDisplayName, + date_display: artwork.objectDate, + image_url: artwork.primaryImageSmall, + }, + }, + { upsert: true }, + ); + + imported++; + console.log(`Imported ${imported}/20: ${artwork.title}`); + } + + console.log(`Completed! Imported ${imported} artworks.`); + } finally { + await client.close(); + } +} + +importArtworks().catch(console.error); diff --git a/backend/src/index.ts b/backend/src/index.ts index 99db3e2..7b743d1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,10 +2,22 @@ import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone"; import { connectToDatabase } from "./db.js"; +// Connect to MongoDB +const db = await connectToDatabase(); + // Define the GraphQL schema const typeDefs = `#graphql + type Artwork { + id: Int! + title: String! + artist_display: String + date_display: String + image_url: String + } + type Query { hello: String! + artworks: [Artwork!]! } `; @@ -13,12 +25,13 @@ const typeDefs = `#graphql const resolvers = { Query: { hello: () => "Hello from Artex backend!", + + artworks: async () => { + return await db.collection("met_artworks").find({}).toArray(); + }, }, }; -// Connect to MongoDB before starting the server -await connectToDatabase(); - // Create the Apollo Server const server = new ApolloServer({ typeDefs, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e5af0b..98edca7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@apollo/client": "^4.3.3", "@tailwindcss/vite": "^4.3.3", + "graphql": "^16.14.2", + "rxjs": "^7.8.2", "lucide-react": "^1.57.0", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -34,6 +37,43 @@ "vite": "^8.3.0" } }, + "node_modules/@apollo/client": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-4.3.3.tgz", + "integrity": "sha512-wMfOUaFKLgNsu0OMCg50gfhEHTV9/GyO7DxQvut7iSBUoePy0kvll3oWeY8KVPRfnVhYEzcwPJ/Jwz4uSe3fQA==", + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "optimism": "^0.18.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "graphql": "^16.0.0 || ^17.0.0", + "graphql-ws": "^5.5.5 || ^6.0.3", + "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "react-dom": "^17.0.0 || ^18.0.0 || >=19.0.0-rc", + "rxjs": "^7.3.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -426,6 +466,15 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "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", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1722,6 +1771,54 @@ } } }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -2321,6 +2418,30 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.7", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.7.tgz", + "integrity": "sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==", + "license": "MIT", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -2845,6 +2966,18 @@ "node": ">=18" } }, + "node_modules/optimism": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.1.tgz", + "integrity": "sha512-mLXNwWPa9dgFyDqkNi54sjDyNJ9/fTI6WGBLgnXku1vdKY/jovHfZT5r+aiVeFFLOz+foPNOm5YJ4mqgld2GBQ==", + "license": "MIT", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.5.0", + "tslib": "^2.3.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3093,6 +3226,15 @@ "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -3195,6 +3337,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5d229cc..58e7725 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "@apollo/client": "^4.3.3", "@tailwindcss/vite": "^4.3.3", + "graphql": "^16.14.2", + "rxjs": "^7.8.2", "lucide-react": "^1.57.0", "react": "^19.2.8", "react-dom": "^19.2.8", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cfddb90..c30d37e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ + import { Routes, Route } from "react-router-dom"; import './index.css' import HomePage from './pages/HomePage' import Login from "./pages/login"; +import Artworks from "./pages/Artworks"; function App() { - return ( } /> @@ -13,4 +14,4 @@ function App() { ); } -export default App +export default App; diff --git a/frontend/src/hooks/useArtworks.ts b/frontend/src/hooks/useArtworks.ts new file mode 100644 index 0000000..1826db4 --- /dev/null +++ b/frontend/src/hooks/useArtworks.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import type { Artwork } from "../types/Artwork"; + +export function useArtworks() { + const [artworks, setArtworks] = useState([]); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchArtworks() { + try { + const response = await fetch("/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query: ` + query { + artworks { + id + title + artist_display + image_url + } + } + `, + }), + }); + 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); + } + + setArtworks(result.data.artworks.slice(0, 12)); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setLoading(false); + } + } + + fetchArtworks(); + }, []); + + return { artworks, loading, error }; +} diff --git a/frontend/src/pages/Artworks.tsx b/frontend/src/pages/Artworks.tsx new file mode 100644 index 0000000..591bb6a --- /dev/null +++ b/frontend/src/pages/Artworks.tsx @@ -0,0 +1,32 @@ +import { useArtworks } from "../hooks/useArtworks"; + +export default function Artworks() { + const { artworks, loading, error } = useArtworks(); + + if (loading) return

Loading artworks...

; + if (error) return

Error: {error}

; + + return ( +
+

Artex gallery

+

Number of artworks: {artworks.length}

+
+ {artworks.map((artwork) => ( +
+

{artwork.title}

+

{artwork.artist_display || "Unknown artist"}

+ + {artwork.image_url && ( + {artwork.title} + )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/types/Artwork.ts b/frontend/src/types/Artwork.ts index f44a2eb..0b55e83 100644 --- a/frontend/src/types/Artwork.ts +++ b/frontend/src/types/Artwork.ts @@ -1,8 +1,7 @@ export type Artwork = { - id: string | number - title: string - artist: string | null - imageUrl: string | null - imageAlt?: string | null - date?: string | null -} + id: number; + title: string; + artist_display: string | null; + image_url: string | null; +}; + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7060f39..cb2a253 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,7 @@ -import react, { reactCompilerPreset } from '@vitejs/plugin-react' -import babel from '@rolldown/plugin-babel' -import { defineConfig } from 'vite' -import tailwindcss from '@tailwindcss/vite' +import react, { reactCompilerPreset } from "@vitejs/plugin-react"; +import babel from "@rolldown/plugin-babel"; +import { defineConfig } from "vite"; +import tailwindcss from "@tailwindcss/vite"; // https://vite.dev/config/ export default defineConfig({ @@ -10,4 +10,14 @@ export default defineConfig({ babel({ presets: [reactCompilerPreset()] }), tailwindcss(), ], -}) + + server: { + proxy: { + "/graphql": { + target: "http://127.0.0.1:3001", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/graphql/, ""), + }, + }, + }, +});