diff --git a/.gitignore b/.gitignore index 3d70248ba..635ca404d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules .env.production.local build +frontend/dist npm-debug.log* yarn-debug.log* diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..d3cb2ac4d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "postman.settings.dotenv-detection-notification-visibility": false +} diff --git a/README.md b/README.md index 31466b54c..9fb2d274e 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,355 @@ -# Final Project +# Chronos — Visual History Timeline -Replace this readme with your own information about your project. +A full-stack interactive history analysis tool that allows users to explore and compare historical events across multiple timelines. -Start by briefly describing the assignment in a sentence or two. Keep it short and to the point. +Tech stack: +React • Node.js • Express • MongoDB • React Query • Zustand -## The problem +# Chronos — Visual History Timeline -Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next? +Chronos is a visual history analysis tool that allows users to explore and compare historical events across multiple domains using interactive timelines. -## View it live +The application makes it possible to place different historical processes on parallel timelines (for example war, medicine, or political change) in order to explore patterns, overlaps, and historical context. -Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about. \ No newline at end of file +The project was built as a full-stack application using React, Node.js, Express, and MongoDB. + +--- + +# Features + +- Interactive timeline visualization +- Multiple timeline layers that can be compared +- Category filtering for each layer +- Event clustering when events occur close in time +- Event detail panel +- User authentication (signup/login) +- Data imported automatically from Wikidata +- Responsive design (mobile → desktop) +- Accessible interface with keyboard navigation + +Example comparison: + +War timeline vs Medicine timeline + +This makes it easier to explore relationships between historical developments across domains. + +--- + +# Tech Stack + +## Frontend + +- React +- React Router +- React Query +- Zustand (global state) +- Vite + +## Backend + +- Node.js +- Express + +## Database + +- MongoDB +- Mongoose + +## External Data + +- Wikidata SPARQL API + +--- + +# Architecture Overview + +The project follows a **feature-based architecture**. + +Instead of organizing files only by type, code is grouped by functionality. + +Example: + +frontend/src/features/ + +layers → fetching layer data +timeline → timeline UI and visualization + +This structure improves scalability and maintainability. + +--- + +# Project Structure + +## Backend + +backend/ + +api/layers +integrations/wikidata +jobs/import +models +routes +middleware + +Important files: + +server.js → Express server +Event.js → timeline event model +Layer.js → timeline layer model +User.js → authentication model + +--- + +## Frontend + +frontend/src/ + +api/ → HTTP client and query keys +app/ → router + React Query client +features/ → feature-based modules +components/ → shared components +layouts/ → layout system +pages/ → main application pages +stores/ → Zustand global state + +Example feature module: + +features/timeline/ + +TimelinePage.jsx +TimelineRow.jsx +EventDot.jsx +YearAxis.jsx +EventPanel.jsx + +--- + +# Data Model + +## Layer + +Represents a domain timeline. + +Example: + +War & Organized Violence +Medicine & Disease + +Fields: + +name +slug +categories +rangeStart +rangeEnd + +--- + +## Event + +Represents a historical event. + +Fields: + +layerId +title +summary +startDate +endDate +category +tags +location +sources +externalIds.wikidataQid +lastSyncedAt + +--- + +# API Endpoints + +GET /api/layers + +Returns available timeline layers. + +GET /api/layers/:id/events + +Returns events for a specific layer. + +Optional query parameters: + +category +from +to + +Example: + +/api/layers/war/events?category=civil_war + +--- + +# Wikidata Import System + +Historical events are imported from Wikidata using SPARQL queries. + +Each domain has its own import job. + +Example jobs: + +war.job.js +medicine.job.js + +Import pipeline: + +SPARQL query +→ map results +→ deduplicate by Wikidata QID +→ upsert into MongoDB + +Example command: + +node jobs/import/war.job.js --dry-run + +Dry run allows testing the import without writing to the database. + +--- + +# Timeline System + +Events are rendered as dots on a horizontal timeline. + +The position is calculated using a helper function: + +dateToPercent() + +This converts event dates into positions on the timeline. + +Events close in time are automatically grouped into clusters. + +Clicking a cluster expands it to reveal individual events. + +--- + +# Authentication + +User authentication is implemented with: + +- email + password +- protected routes +- JWT authentication +- Zustand auth state + +Authenticated users can access the main application area. + +--- + +# Responsive Design + +The application is designed mobile-first and works on screens between: + +320px → 1600px + +Mobile improvements include: + +- wrapped layer selector +- touch-friendly event dots +- flexible timeline layout + +--- + +# Accessibility + +Accessibility improvements include: + +- semantic HTML elements +- keyboard-accessible timeline events +- focus states +- ARIA labels +- reduced motion support + +The goal is to achieve a Lighthouse accessibility score of 100. + +--- + +# Running the Project Locally + +## 1. Clone repository + +git clone https://github.com/your-username/chronos + +--- + +## 2. Backend setup + +cd backend + +npm install + +Create a .env file: + +MONGO_URI=your-mongodb-uri +JWT_SECRET=your-secret + +Run server: + +npm run dev + +--- + +## 3. Frontend setup + +cd frontend + +npm install + +Run development server: + +npm run dev + +The frontend will typically run on: + +http://localhost:5173 + +--- + +# Demo Walkthrough + +1. Register a user account +2. Log in to the application +3. Select one or two timeline layers +4. Filter by category +5. Click events to open the detail panel +6. Expand clusters to inspect individual events +7. Compare historical patterns across domains + +--- + +# Future Improvements + +Possible future improvements include: + +- timeline zoom and custom year ranges +- smoother timeline animations +- improved clustering algorithm +- additional timeline layers +- saved timeline comparisons +- user annotations + +--- + +# Learning Goals + +This project was built to explore: + +- full-stack application architecture +- data visualization with React +- working with external data APIs +- designing scalable frontend structure +- building accessible interfaces + +--- + +# Author + +Sara Enderborg + +Chronos combines historical analysis with interactive data visualization to explore patterns across time. diff --git a/backend/README.md b/backend/README.md index d1438c910..b3941d68a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,8 +1,8 @@ -# Backend part of Final Project +# Backend -This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with. +Node.js + Express API for the Chronos timeline application. -## Getting Started +Run locally: -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +npm install +npm run dev diff --git a/backend/api/layers/layers.controller.js b/backend/api/layers/layers.controller.js new file mode 100644 index 000000000..e865421bd --- /dev/null +++ b/backend/api/layers/layers.controller.js @@ -0,0 +1,35 @@ +import { getPublicLayers, getLayerEvents } from "./layers.service.js"; + +export async function listLayers(_req, res) { + try { + const layers = await getPublicLayers(); + res.json({ + success: true, + message: "Layers fetched", + response: layers, + }); + } catch (error) { + res.status(500).json({ + success: false, + message: "Failed to fetch layers", + response: error.message, + }); + } +} + +export async function listLayerEvents(req, res) { + try { + const data = await getLayerEvents(req.params.id, req.query); + res.json({ + success: true, + message: "Events fetched", + response: data, + }); + } catch (error) { + res.status(error.status || 500).json({ + success: false, + message: error.message || "Failed to fetch events", + response: null, + }); + } +} diff --git a/backend/api/layers/layers.routes.js b/backend/api/layers/layers.routes.js new file mode 100644 index 000000000..f4cc607cf --- /dev/null +++ b/backend/api/layers/layers.routes.js @@ -0,0 +1,9 @@ +import express from "express"; +import { listLayers, listLayerEvents } from "./layers.controller.js"; + +const router = express.Router(); + +router.get("/", listLayers); +router.get("/:id/events", listLayerEvents); + +export default router; diff --git a/backend/api/layers/layers.service.js b/backend/api/layers/layers.service.js new file mode 100644 index 000000000..868b14921 --- /dev/null +++ b/backend/api/layers/layers.service.js @@ -0,0 +1,54 @@ +import Layer from "../../models/Layer.js"; +import Event from "../../models/Event.js"; + +export async function getPublicLayers() { + return Layer.find({ isPublic: true, ownerId: null }) + .sort({ createdAt: 1 }) + .lean(); +} + +export async function getLayerEvents(layerId, { from, to, category, tag }) { + const layer = await Layer.findById(layerId).lean(); + if (!layer) { + const err = new Error("Layer not found"); + err.status = 404; + throw err; + } + + const fromDate = from ? new Date(from) : layer.rangeStart; + const toDate = to ? new Date(to) : layer.rangeEnd; + + if (Number.isNaN(fromDate.getTime()) || Number.isNaN(toDate.getTime())) { + const err = new Error("Invalid date format. Use YYYY-MM-DD"); + err.status = 400; + throw err; + } + + const query = { + layerId: layer._id, + $or: [ + { startDate: { $gte: fromDate, $lte: toDate } }, + { endDate: { $gte: fromDate, $lte: toDate } }, + { startDate: { $lte: fromDate }, endDate: { $gte: toDate } }, + ], + }; + + if (category) query.category = category; + if (tag) query.tags = tag; + + const events = await Event.find(query).sort({ startDate: 1 }).lean(); + + return { + layer: { + _id: layer._id, + name: layer.name, + slug: layer.slug, + region: layer.region, + categories: layer.categories, + }, + from: fromDate, + to: toDate, + count: events.length, + events, + }; +} diff --git a/backend/db/seed/layers.seed.js b/backend/db/seed/layers.seed.js new file mode 100644 index 000000000..9398d00ea --- /dev/null +++ b/backend/db/seed/layers.seed.js @@ -0,0 +1,50 @@ +export const layersSeed = [ + { + name: "War & Organized Violence", + slug: "war_organized_violence_europe", + region: "Europe", + rangeStart: new Date("1500-01-01"), + rangeEnd: new Date("2000-12-31"), + categories: [ + "interstate_wars", + "civil_wars", + "revolutions_uprisings", + "genocides_mass_violence", + "military_alliances", + ], + isPublic: true, + ownerId: null, + }, + { + name: "Medicine & Disease", + slug: "medicine_disease_europe", + region: "Europe", + rangeStart: new Date("1500-01-01"), + rangeEnd: new Date("2000-12-31"), + categories: [ + "major_epidemics_pandemics", + "vaccines", + "medical_breakthroughs", + "public_health_reforms", + "hospital_systems", + "germ_theory_bacteriology", + ], + isPublic: true, + ownerId: null, + }, + { + name: "Technology & Inventions", + slug: "technology_inventions_europe", + region: "Europe", + rangeStart: new Date("1500-01-01"), + rangeEnd: new Date("2000-12-31"), + categories: [ + "industrial", + "communication", + "transport", + "scientific_invention", + ], + isPublic: true, + ownerId: null, + }, +]; diff --git a/backend/db/seed/seedLayers.js b/backend/db/seed/seedLayers.js new file mode 100644 index 000000000..28fa739a4 --- /dev/null +++ b/backend/db/seed/seedLayers.js @@ -0,0 +1,72 @@ +import "dotenv/config"; +import mongoose from "mongoose"; +import Layer from "../../models/Layer.js"; +import { layersSeed } from "./layers.seed.js"; + +async function connectDB() { + const uri = process.env.MONGO_URI; + if (!uri) throw new Error("Missing MONGO_URI in .env"); + await mongoose.connect(uri); +} + +function summarizeBulkResult(result) { + // Mongoose/MongoDB kan skilja i hur resultatobjektet ser ut mellan versioner, + // så plockar säkert ut “vanliga” fält. + return { + insertedCount: result?.insertedCount ?? 0, + matchedCount: result?.matchedCount ?? 0, + modifiedCount: result?.modifiedCount ?? 0, + upsertedCount: result?.upsertedCount ?? 0, + upsertedIds: result?.upsertedIds ?? result?.getUpsertedIds?.() ?? [], + }; +} + +async function seedLayers() { + await connectDB(); + + console.log("Seeding layers..."); + console.log( + "Targets:", + layersSeed.map((l) => l.slug), + ); + + const ops = layersSeed.map((layerDoc) => ({ + updateOne: { + filter: { slug: layerDoc.slug }, + update: { $set: layerDoc }, + upsert: true, + }, + })); + + const bulkResult = await Layer.bulkWrite(ops, { ordered: true }); + const summary = summarizeBulkResult(bulkResult); + + const total = await Layer.countDocuments(); + const systemLayers = await Layer.countDocuments({ ownerId: null }); + const publicLayers = await Layer.countDocuments({ isPublic: true }); + + console.log("Seed complete."); + console.log("Bulk summary:", summary); + console.log("DB counts:", { total, systemLayers, publicLayers }); + + // Visa vad som ligger i DB (snabbt och tydligt) + const current = await Layer.find( + { slug: { $in: layersSeed.map((l) => l.slug) } }, + { _id: 1, slug: 1, name: 1, region: 1, isPublic: 1, ownerId: 1 }, + ).lean(); + + console.log("Seeded docs (slug -> _id):"); + for (const doc of current) { + console.log(`- ${doc.slug} -> ${doc._id}`); + } + + await mongoose.disconnect(); +} + +seedLayers().catch(async (err) => { + console.error("Seeding failed:", err); + try { + await mongoose.disconnect(); + } catch (_) {} + process.exit(1); +}); diff --git a/backend/integrations/wikidata/queries/_shared.js b/backend/integrations/wikidata/queries/_shared.js new file mode 100644 index 000000000..81115c2e3 --- /dev/null +++ b/backend/integrations/wikidata/queries/_shared.js @@ -0,0 +1,69 @@ +export function formatDate(date) { + return date.toISOString().split("T")[0]; +} + +export function basePrefixes() { + return ` +PREFIX xsd: +PREFIX schema: + `.trim(); +} + +export function baseSelect() { + return ` +SELECT DISTINCT + ?event ?eventLabel ?eventDescription + ?startDate ?endDate + ?countryLabel ?locationLabel + ?type ?typeLabel + ?article +WHERE { + `.trim(); +} + +export function baseEuropeFilter() { + return ` + { + ?event wdt:P17 ?country . + ?country wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P276 ?location . + ?location wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P30 wd:Q46 . + } + + OPTIONAL { ?event wdt:P17 ?country . } + OPTIONAL { ?event wdt:P276 ?location . } + `.trim(); +} + +export function baseDates(from, to) { + return ` + { + ?event wdt:P580 ?startDate . + } UNION { + ?event wdt:P577 ?startDate . + } + + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + OPTIONAL { ?event wdt:P582 ?endDate . } + `.trim(); +} + +export function baseWikipedia() { + return ` + OPTIONAL { + ?article schema:about ?event . + ?article schema:inLanguage "en" . + FILTER(STRSTARTS(STR(?article), "https://en.wikipedia.org/")) + } + + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +} +ORDER BY ?startDate +LIMIT 2000 + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/_shared.js b/backend/integrations/wikidata/queries/medicine/_shared.js new file mode 100644 index 000000000..d6496e84e --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/_shared.js @@ -0,0 +1,15 @@ +export { + formatDate, + basePrefixes, + baseSelect, + baseEuropeFilter, + baseDates, + baseWikipedia, +} from "../_shared.js"; + +// Backwards-compatible aliases (om några medicine queries använder andra namn) +export { basePrefixes as prefixes } from "../_shared.js"; +export { baseSelect as selectBase } from "../_shared.js"; +export { baseEuropeFilter as europeFilter } from "../_shared.js"; +export { baseDates as dateFilterP580 } from "../_shared.js"; +export { baseWikipedia as wikipediaAndLabels } from "../_shared.js"; diff --git a/backend/integrations/wikidata/queries/medicine/epidemics.query.js b/backend/integrations/wikidata/queries/medicine/epidemics.query.js new file mode 100644 index 000000000..901af90ec --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/epidemics.query.js @@ -0,0 +1,37 @@ +import { + basePrefixes, + baseSelect, + baseDates, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +export default function buildEpidemicsQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q44512 # epidemic + wd:Q1516910 # plague epidemic + wd:Q2723958 # influenza pandemic + wd:Q178561 # pandemic + wd:Q1369832 # disease outbreak + } + + ?event wdt:P31 ?type . + +${baseDates(from, to)} + + # Exclude military conflicts (safety) + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/germTheory.query.js b/backend/integrations/wikidata/queries/medicine/germTheory.query.js new file mode 100644 index 000000000..0cc58314f --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/germTheory.query.js @@ -0,0 +1,34 @@ +import { + basePrefixes, + baseSelect, + baseDates, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +export default function buildGermTheoryQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q13442814 # germ theory of disease + wd:Q79948 # bacteriology + wd:Q7944 # microbiology + } + + ?event wdt:P31 ?type . + +${baseDates(from, to)} + + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/hospitals.query.js b/backend/integrations/wikidata/queries/medicine/hospitals.query.js new file mode 100644 index 000000000..4db590472 --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/hospitals.query.js @@ -0,0 +1,36 @@ +import { + basePrefixes, + baseSelect, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +export default function buildHospitalsQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q16917 # hospital + wd:Q1774898 # clinic + } + + ?event wdt:P31 ?type . + + { + ?event wdt:P571 ?startDate . # inception for institutions + } + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/medicalDiscoveries.query.js b/backend/integrations/wikidata/queries/medicine/medicalDiscoveries.query.js new file mode 100644 index 000000000..1f694a668 --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/medicalDiscoveries.query.js @@ -0,0 +1,40 @@ +import { + basePrefixes, + baseSelect, + baseDates, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +/** + * Fetches major medical discoveries and procedures in Europe + * within a given date range. + * + * Category: medical_breakthroughs + */ +export default function buildMedicalDiscoveriesQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q7314688 # medical discovery + wd:Q796194 # medical procedure + } + + ?event wdt:P31 ?type . + +${baseDates(from, to)} + + # Safety: exclude military-related entities + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/medicine.query.js b/backend/integrations/wikidata/queries/medicine/medicine.query.js new file mode 100644 index 000000000..262f45496 --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/medicine.query.js @@ -0,0 +1,66 @@ +export default function buildMedicineQuery(rangeStart, rangeEnd) { + const from = rangeStart.toISOString().split("T")[0]; + const to = rangeEnd.toISOString().split("T")[0]; + + return ` +PREFIX xsd: +PREFIX schema: + +SELECT DISTINCT + ?event ?eventLabel ?eventDescription + ?startDate ?endDate + ?countryLabel ?locationLabel + ?type ?typeLabel + ?article +WHERE { + VALUES ?type { + wd:Q44512 # epidemic + wd:Q1516910 # plague epidemic + wd:Q2723958 # influenza pandemic + wd:Q178561 # pandemic + wd:Q1369832 # disease outbreak + wd:Q11461 # vaccine + wd:Q796194 # medical procedure + wd:Q7314688 # medical discovery + } + + ?event wdt:P31 ?type . + + { + ?event wdt:P580 ?startDate . + } UNION { + ?event wdt:P577 ?startDate . + } + + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + # Exclude military conflicts + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + + { + ?event wdt:P17 ?country . + ?country wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P276 ?location . + ?location wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P30 wd:Q46 . + } + + OPTIONAL { ?event wdt:P582 ?endDate . } + OPTIONAL { ?event wdt:P17 ?country . } + OPTIONAL { ?event wdt:P276 ?location . } + + OPTIONAL { + ?article schema:about ?event . + ?article schema:inLanguage "en" . + FILTER(STRSTARTS(STR(?article), "https://en.wikipedia.org/")) + } + + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +} +ORDER BY ?startDate +LIMIT 2000 + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/publicHealth.query.js b/backend/integrations/wikidata/queries/medicine/publicHealth.query.js new file mode 100644 index 000000000..448e625bb --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/publicHealth.query.js @@ -0,0 +1,34 @@ +import { + basePrefixes, + baseSelect, + baseDates, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +export default function buildPublicHealthQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q189533 # public health + wd:Q284465 # sanitation + wd:Q133500 # hygiene + } + + ?event wdt:P31 ?type . + +${baseDates(from, to)} + + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/medicine/vaccines.query.js b/backend/integrations/wikidata/queries/medicine/vaccines.query.js new file mode 100644 index 000000000..4f77f5be2 --- /dev/null +++ b/backend/integrations/wikidata/queries/medicine/vaccines.query.js @@ -0,0 +1,32 @@ +import { + basePrefixes, + baseSelect, + baseDates, + baseEuropeFilter, + baseWikipedia, + formatDate, +} from "./_shared.js"; + +export default function buildVaccinesQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q134808 # vaccination + } + + ?event wdt:P31 ?type . + +${baseDates(from, to)} + + FILTER NOT EXISTS { ?event wdt:P31/wdt:P279* wd:Q180684 . } + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/technology/_shared.js b/backend/integrations/wikidata/queries/technology/_shared.js new file mode 100644 index 000000000..047f47015 --- /dev/null +++ b/backend/integrations/wikidata/queries/technology/_shared.js @@ -0,0 +1,31 @@ +export { + formatDate, + basePrefixes, + baseSelect, + baseEuropeFilter, + baseWikipedia, +} from "../_shared.js"; + +// Backwards-compatible aliases +export { basePrefixes as prefixes } from "../_shared.js"; +export { baseSelect as selectBase } from "../_shared.js"; +export { baseEuropeFilter as europeFilter } from "../_shared.js"; +export { baseWikipedia as wikipediaAndLabels } from "../_shared.js"; + +// Technology-specific date filter. +// Inventions and discoveries use P571 (inception) rather than +// P580 (start time) or P577 (publication date), so include all three. +export function techDates(from, to) { + return ` + { + ?event wdt:P571 ?startDate . + } UNION { + ?event wdt:P580 ?startDate . + } UNION { + ?event wdt:P577 ?startDate . + } + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + OPTIONAL { ?event wdt:P582 ?endDate . } + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/technology/communication.query.js b/backend/integrations/wikidata/queries/technology/communication.query.js new file mode 100644 index 000000000..5ee3ac7d4 --- /dev/null +++ b/backend/integrations/wikidata/queries/technology/communication.query.js @@ -0,0 +1,30 @@ +import { + formatDate, + basePrefixes, + baseSelect, + baseEuropeFilter, + baseWikipedia, + techDates, +} from "./_shared.js"; + +export default function buildCommunicationQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q11032 # newspaper + } + + ?event wdt:P31 ?type . + +${techDates(from, to)} + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/technology/industrial.query.js b/backend/integrations/wikidata/queries/technology/industrial.query.js new file mode 100644 index 000000000..4469d64bb --- /dev/null +++ b/backend/integrations/wikidata/queries/technology/industrial.query.js @@ -0,0 +1,31 @@ +import { + formatDate, + basePrefixes, + baseSelect, + baseEuropeFilter, + baseWikipedia, + techDates, +} from "./_shared.js"; + +export default function buildIndustrialQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q820477 # mine + wd:Q190117 # ironworks + } + + ?event wdt:P31 ?type . + +${techDates(from, to)} + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/technology/scientificInvention.query.js b/backend/integrations/wikidata/queries/technology/scientificInvention.query.js new file mode 100644 index 000000000..67bdbd3a3 --- /dev/null +++ b/backend/integrations/wikidata/queries/technology/scientificInvention.query.js @@ -0,0 +1,30 @@ +import { + formatDate, + basePrefixes, + baseSelect, + baseEuropeFilter, + baseWikipedia, + techDates, +} from "./_shared.js"; + +export default function buildScientificInventionQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q253623 # patent + } + + ?event wdt:P31 ?type . + +${techDates(from, to)} + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/technology/transport.query.js b/backend/integrations/wikidata/queries/technology/transport.query.js new file mode 100644 index 000000000..434d27214 --- /dev/null +++ b/backend/integrations/wikidata/queries/technology/transport.query.js @@ -0,0 +1,32 @@ +import { + formatDate, + basePrefixes, + baseSelect, + baseEuropeFilter, + baseWikipedia, + techDates, +} from "./_shared.js"; + +export default function buildTransportQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${basePrefixes()} + +${baseSelect()} + VALUES ?type { + wd:Q728937 # steam locomotive / railway line + wd:Q12284 # canal + wd:Q44782 # port + } + + ?event wdt:P31 ?type . + +${techDates(from, to)} + +${baseEuropeFilter()} + +${baseWikipedia()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war.query.js b/backend/integrations/wikidata/queries/war.query.js new file mode 100644 index 000000000..4a3337afe --- /dev/null +++ b/backend/integrations/wikidata/queries/war.query.js @@ -0,0 +1,66 @@ +/** + * Returns a SPARQL query for fetching wars and organized violence in Europe + * from Wikidata within a given date range. + * + * @param {Date} rangeStart + * @param {Date} rangeEnd + * @returns {string} SPARQL query + */ +export default function buildWarQuery(rangeStart, rangeEnd) { + const from = rangeStart.toISOString().split("T")[0]; + const to = rangeEnd.toISOString().split("T")[0]; + + return ` + + PREFIX xsd: + PREFIX schema: + +SELECT DISTINCT + ?event ?eventLabel ?eventDescription + ?startDate ?endDate + ?countryLabel ?locationLabel + ?type + ?article +WHERE { + VALUES ?type { + wd:Q198 # war + wd:Q8465 # civil war + wd:Q13418847 # genocide + wd:Q467011 # rebellion + wd:Q152786 # revolution + wd:Q1261499 # military conflict + wd:Q188055 # massacre + } + + ?event wdt:P31 ?type . + ?event wdt:P580 ?startDate . + + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + { + ?event wdt:P17 ?country . + ?country wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P276 ?location . + ?location wdt:P30 wd:Q46 . + } UNION { + ?event wdt:P30 wd:Q46 . + } + + OPTIONAL { ?event wdt:P582 ?endDate . } + OPTIONAL { ?event wdt:P17 ?country . } + OPTIONAL { ?event wdt:P276 ?location . } + + OPTIONAL { + ?article schema:about ?event . + ?article schema:inLanguage "en" . + FILTER(STRSTARTS(STR(?article), "https://en.wikipedia.org/")) + } + + SERVICE wikibase:label { bd:serviceParam wikibase:language "en". } +} +ORDER BY ?startDate +LIMIT 2000 + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war/_shared.js b/backend/integrations/wikidata/queries/war/_shared.js new file mode 100644 index 000000000..268e5ade9 --- /dev/null +++ b/backend/integrations/wikidata/queries/war/_shared.js @@ -0,0 +1,32 @@ +export { + formatDate, + basePrefixes, + baseSelect, + baseEuropeFilter, + baseDates, + baseWikipedia, +} from "../_shared.js"; + +// Backwards-compatible aliases (så gamla war queries fortsätter funka) +export { basePrefixes as prefixes } from "../_shared.js"; +export { baseSelect as selectBase } from "../_shared.js"; +export { baseEuropeFilter as europeFilter } from "../_shared.js"; +export { baseDates as dateFilterP580 } from "../_shared.js"; +export { baseWikipedia as wikipediaAndLabels } from "../_shared.js"; + +// War-specific helper (used by e.g. militaryAlliances.query.js) +export function dateFilterAlliance(from, to) { + return ` + { + ?event wdt:P571 ?startDate . # inception + } UNION { + ?event wdt:P580 ?startDate . # start time (fallback) + } + + FILTER(?startDate >= "${from}"^^xsd:dateTime) + FILTER(?startDate <= "${to}"^^xsd:dateTime) + + OPTIONAL { ?event wdt:P576 ?endDate . } # dissolved/abolished + OPTIONAL { ?event wdt:P582 ?endDate . } # end time (fallback) + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war/civilWars.query.js b/backend/integrations/wikidata/queries/war/civilWars.query.js new file mode 100644 index 000000000..c936ec9ca --- /dev/null +++ b/backend/integrations/wikidata/queries/war/civilWars.query.js @@ -0,0 +1,30 @@ +import { + prefixes, + selectBase, + europeFilter, + dateFilterP580, + wikipediaAndLabels, + formatDate, +} from "./_shared.js"; + +export default function buildCivilWarsQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${prefixes()} + +${selectBase()} + VALUES ?type { + wd:Q8465 # civil war + } + + ?event wdt:P31 ?type . + +${dateFilterP580(from, to)} + +${europeFilter()} + +${wikipediaAndLabels()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war/genocidesMassViolence.query.js b/backend/integrations/wikidata/queries/war/genocidesMassViolence.query.js new file mode 100644 index 000000000..1e36c6727 --- /dev/null +++ b/backend/integrations/wikidata/queries/war/genocidesMassViolence.query.js @@ -0,0 +1,40 @@ +import { + prefixes, + selectBase, + europeFilter, + dateFilterP580, + wikipediaAndLabels, + formatDate, +} from "./_shared.js"; + +export default function buildGenocidesMassViolenceQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${prefixes()} + +${selectBase()} + VALUES ?type { + wd:Q13418847 # genocide + } + + ?event wdt:P31 ?type . + + # Guard rails: keep this bucket pedagogically clean + FILTER NOT EXISTS { ?event wdt:P31 wd:Q152786 } # revolution + FILTER NOT EXISTS { ?event wdt:P31 wd:Q467011 } # rebellion + FILTER NOT EXISTS { ?event wdt:P31 wd:Q8465 } # civil war + FILTER NOT EXISTS { ?event wdt:P31 wd:Q198 } # war + FILTER NOT EXISTS { ?event wdt:P31 wd:Q1261499 } # military conflict + +${dateFilterP580(from, to)} + +${europeFilter()} + +${wikipediaAndLabels()} + `.trim(); +} + +// NOTE:- massacre (wd:Q188055) intentionally excluded due to noise +// wd:Q188055 # massacre- picks up on the wrong things, possibly add “ethnic cleansing” later move massacre out of war (or make it opt-in later) diff --git a/backend/integrations/wikidata/queries/war/interstateWars.query.js b/backend/integrations/wikidata/queries/war/interstateWars.query.js new file mode 100644 index 000000000..1aab0c6cb --- /dev/null +++ b/backend/integrations/wikidata/queries/war/interstateWars.query.js @@ -0,0 +1,38 @@ +import { + prefixes, + selectBase, + europeFilter, + dateFilterP580, + wikipediaAndLabels, + formatDate, +} from "./_shared.js"; + +export default function buildInterstateWarsQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${prefixes()} + +${selectBase()} + VALUES ?type { + wd:Q198 # war + wd:Q1261499 # military conflict + } + + ?event wdt:P31 ?type . + + # Exclude categories handled by other queries + FILTER NOT EXISTS { ?event wdt:P31 wd:Q8465 } # civil war + FILTER NOT EXISTS { ?event wdt:P31 wd:Q152786 } # revolution + FILTER NOT EXISTS { ?event wdt:P31 wd:Q467011 } # rebellion + FILTER NOT EXISTS { ?event wdt:P31 wd:Q13418847 } # genocide + FILTER NOT EXISTS { ?event wdt:P31 wd:Q188055 } # massacre + +${dateFilterP580(from, to)} + +${europeFilter()} + +${wikipediaAndLabels()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war/militaryAlliances.query.js b/backend/integrations/wikidata/queries/war/militaryAlliances.query.js new file mode 100644 index 000000000..2bd9f0773 --- /dev/null +++ b/backend/integrations/wikidata/queries/war/militaryAlliances.query.js @@ -0,0 +1,27 @@ +import { + prefixes, + selectBase, + europeFilter, + dateFilterAlliance, + wikipediaAndLabels, + formatDate, +} from "./_shared.js"; + +export default function buildMilitaryAlliancesQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${prefixes()} + +${selectBase()} + # military alliance (includes subclasses) + ?event wdt:P31/wdt:P279* wd:Q1127126 . + +${dateFilterAlliance(from, to)} + +${europeFilter()} + +${wikipediaAndLabels()} + `.trim(); +} diff --git a/backend/integrations/wikidata/queries/war/revolutionsUprisings.query.js b/backend/integrations/wikidata/queries/war/revolutionsUprisings.query.js new file mode 100644 index 000000000..edcf8c23b --- /dev/null +++ b/backend/integrations/wikidata/queries/war/revolutionsUprisings.query.js @@ -0,0 +1,31 @@ +import { + prefixes, + selectBase, + europeFilter, + dateFilterP580, + wikipediaAndLabels, + formatDate, +} from "./_shared.js"; + +export default function buildRevolutionsUprisingsQuery(rangeStart, rangeEnd) { + const from = formatDate(rangeStart); + const to = formatDate(rangeEnd); + + return ` +${prefixes()} + +${selectBase()} + VALUES ?type { + wd:Q152786 # revolution + wd:Q467011 # rebellion + } + + ?event wdt:P31 ?type . + +${dateFilterP580(from, to)} + +${europeFilter()} + +${wikipediaAndLabels()} + `.trim(); +} diff --git a/backend/integrations/wikidata/utils/_mapperUtils.js b/backend/integrations/wikidata/utils/_mapperUtils.js new file mode 100644 index 000000000..294796424 --- /dev/null +++ b/backend/integrations/wikidata/utils/_mapperUtils.js @@ -0,0 +1,59 @@ +/** + * Extracts a Wikidata QID from an entity URI. + * e.g. "http://www.wikidata.org/entity/Q362" → "Q362" + * + * @param {string} uri + * @returns {string|null} + */ +export function extractQid(uri) { + if (!uri) return null; + const match = uri.match(/Q\d+$/); + return match ? match[0] : null; +} + +/** + * Builds an Event document from a Wikidata SPARQL result row. + * Used by all mappers (war, medicine, science, etc.). + * + * @param {Object} row - SPARQL result row from Wikidata + * @param {string} layerId - MongoDB ObjectId for the layer + * @param {Function} mapCategory - Category mapping function specific to the layer + * @returns {Object|null} Event document, or null if the row is invalid + */ +export function buildEventDoc(row, layerId, mapCategory) { + const qid = extractQid(row.event?.value); + if (!qid) return null; + + const title = row.eventLabel?.value; + if (!title || title === qid) return null; + + const startDate = row.startDate?.value ? new Date(row.startDate.value) : null; + if (!startDate || isNaN(startDate.getTime())) return null; + + const endDate = row.endDate?.value ? new Date(row.endDate.value) : null; + const location = row.locationLabel?.value || row.countryLabel?.value || null; + + const sources = []; + if (row.article?.value) { + sources.push({ label: "Wikipedia (en)", url: row.article.value }); + } + sources.push({ + label: "Wikidata", + url: `https://www.wikidata.org/wiki/${qid}`, + }); + + return { + layerId, + title, + summary: row.eventDescription?.value || null, + startDate, + endDate: endDate || null, + category: mapCategory(row), + tags: [], + location, + sources, + wikimedia: null, + externalIds: { wikidataQid: qid }, + lastSyncedAt: new Date(), + }; +} diff --git a/backend/jobs/import/_runImport.js b/backend/jobs/import/_runImport.js new file mode 100644 index 000000000..379c39087 --- /dev/null +++ b/backend/jobs/import/_runImport.js @@ -0,0 +1,46 @@ +import dotenv from "dotenv"; +import mongoose from "mongoose"; + +dotenv.config({ path: new URL("../../.env", import.meta.url), quiet: true }); + +export async function runImport({ + importFn, + jobName, + dryRun = false, + connectDB = false, +}) { + if (connectDB) { + const uri = process.env.MONGO_URI; + if (!uri) throw new Error("Missing MONGO_URI in .env"); + await mongoose.connect(uri); + console.log(`[${jobName}] Connected to MongoDB`); + } + + try { + console.log(`[${jobName}] Starting import...${dryRun ? " (dry run)" : ""}`); + const result = await importFn({ dryRun }); + console.log(`[${jobName}] Done:`, result); + return result; + } finally { + if (connectDB) { + await mongoose.disconnect(); + console.log(`[${jobName}] Disconnected.`); + } + } +} + +export function startFromCLI( + filename, + importFn, + jobName = filename.replace(".js", ""), +) { + const isMain = process.argv[1]?.endsWith(filename); + if (!isMain) return; + + const dryRun = process.argv.includes("--dry-run"); + + runImport({ importFn, jobName, dryRun, connectDB: true }).catch((err) => { + console.error(`[${jobName}] Import failed:`, err); + process.exit(1); + }); +} diff --git a/backend/jobs/import/medicine.job.js b/backend/jobs/import/medicine.job.js new file mode 100644 index 000000000..921a82044 --- /dev/null +++ b/backend/jobs/import/medicine.job.js @@ -0,0 +1,246 @@ +import "dotenv/config"; +import Layer from "../../models/Layer.js"; +import Event from "../../models/Event.js"; + +import buildEpidemicsQuery from "../../integrations/wikidata/queries/medicine/epidemics.query.js"; +import buildVaccinesQuery from "../../integrations/wikidata/queries/medicine/vaccines.query.js"; +import buildMedicalDiscoveriesQuery from "../../integrations/wikidata/queries/medicine/medicalDiscoveries.query.js"; +import buildPublicHealthQuery from "../../integrations/wikidata/queries/medicine/publicHealth.query.js"; +import buildGermTheoryQuery from "../../integrations/wikidata/queries/medicine/germTheory.query.js"; + +import { buildEventDoc } from "../../integrations/wikidata/utils/_mapperUtils.js"; +import { startFromCLI } from "./_runImport.js"; + +const WIKIDATA_ENDPOINT = "https://query.wikidata.org/sparql"; +const USER_AGENT = + "HistoryTimelineApp/1.0 (educational project; contact: sara@example.com)"; + +/** + * Fetches raw SPARQL results from Wikidata. + * + * @param {string} sparql + * @returns {Promise} + */ +async function fetchFromWikidata(sparql, { retries = 3 } = {}) { + const url = new URL(WIKIDATA_ENDPOINT); + url.searchParams.set("query", sparql); + url.searchParams.set("format", "json"); + + let lastErr; + + for (let attempt = 1; attempt <= retries; attempt++) { + try { + const response = await fetch(url.toString(), { + headers: { + Accept: "application/sparql-results+json", + "User-Agent": USER_AGENT, + }, + }); + + if (!response.ok) { + const text = await response.text(); + + if ( + [429, 502, 503, 504].includes(response.status) && + attempt < retries + ) { + const delayMs = 800 * attempt; + console.log( + `Wikidata ${response.status} (attempt ${attempt}/${retries}) – retrying in ${delayMs}ms...`, + ); + await new Promise((r) => setTimeout(r, delayMs)); + continue; + } + + throw new Error(`Wikidata responded with ${response.status}: ${text}`); + } + + const data = await response.json(); + return data.results.bindings; + } catch (err) { + lastErr = err; + if (attempt < retries) { + const delayMs = 800 * attempt; + console.log( + `Fetch failed (attempt ${attempt}/${retries}) – retrying in ${delayMs}ms...`, + ); + await new Promise((r) => setTimeout(r, delayMs)); + continue; + } + } + } + + throw lastErr; +} + +function dedupeByWikidataQid(docs) { + const map = new Map(); + + for (const doc of docs) { + const qid = doc?.externalIds?.wikidataQid; + if (!qid) continue; + + const existing = map.get(qid); + if (!existing) { + map.set(qid, doc); + continue; + } + + const existingHasLocation = !!existing.location; + const docHasLocation = !!doc.location; + + if (!existingHasLocation && docHasLocation) { + map.set(qid, doc); + } + } + + return [...map.values()]; +} + +function makeFixedCategoryMapper(category) { + return (row, layerId) => buildEventDoc(row, layerId, () => category); +} + +/** + * Runs one category import (one query). + */ +async function importCategory({ layer, category, buildQuery, dryRun }) { + console.log(`\n→ Importing category: ${category}`); + + const sparql = buildQuery(layer.rangeStart, layer.rangeEnd); + const rows = await fetchFromWikidata(sparql); + console.log(`Wikidata returned ${rows.length} rows`); + + const mapRow = makeFixedCategoryMapper(category); + + const docsRaw = rows.map((r) => mapRow(r, layer._id)).filter(Boolean); + const docs = dedupeByWikidataQid(docsRaw); + + console.log(`Deduped ${docsRaw.length} → ${docs.length} by wikidataQid`); + console.log( + `Mapped ${docs.length} valid events (${rows.length - docs.length} skipped)`, + ); + + if (dryRun) { + console.log("Sample (first 2):", JSON.stringify(docs.slice(0, 2), null, 2)); + return { + category, + total: rows.length, + mapped: docs.length, + upserted: 0, + modified: 0, + }; + } + + if (docs.length === 0) { + return { + category, + total: rows.length, + mapped: 0, + upserted: 0, + modified: 0, + }; + } + + const ops = docs.map((doc) => ({ + updateOne: { + filter: { + layerId: doc.layerId, + "externalIds.wikidataQid": doc.externalIds.wikidataQid, + }, + update: { $set: doc }, + upsert: true, + }, + })); + + const result = await Event.bulkWrite(ops, { ordered: false }); + + return { + category, + total: rows.length, + mapped: docs.length, + upserted: result.upsertedCount, + modified: result.modifiedCount, + }; +} + +/** + * Runs the medicine import job (6 separate queries). + * + * @param {{ dryRun?: boolean }} options + * @returns {Promise} Import summary + */ +export async function runMedicineImport({ dryRun = false } = {}) { + const layer = await Layer.findOne({ slug: "medicine_disease_europe" }).lean(); + if (!layer) + throw new Error("Medicine layer not found. Have you run the seed?"); + + console.log(`Layer: ${layer.name} (${layer._id})`); + console.log( + `Range: ${layer.rangeStart.toISOString()} → ${layer.rangeEnd.toISOString()}`, + ); + + const tasks = [ + { + category: "major_epidemics_pandemics", + buildQuery: buildEpidemicsQuery, + }, + { + category: "vaccines", + buildQuery: buildVaccinesQuery, + }, + { + category: "medical_breakthroughs", + buildQuery: buildMedicalDiscoveriesQuery, + }, + { + category: "public_health_reforms", + buildQuery: buildPublicHealthQuery, + }, + { + category: "germ_theory_bacteriology", + buildQuery: buildGermTheoryQuery, + }, + ]; + + const results = []; + for (const t of tasks) { + try { + const r = await importCategory({ + layer, + category: t.category, + buildQuery: t.buildQuery, + dryRun, + }); + results.push(r); + } catch (err) { + console.log( + `Skipping category ${t.category} due to error: ${err.message}`, + ); + results.push({ + category: t.category, + total: 0, + mapped: 0, + upserted: 0, + modified: 0, + error: err.message, + }); + } + } + const summary = results.reduce( + (acc, r) => { + acc.total += r.total; + acc.mapped += r.mapped; + acc.upserted += r.upserted; + acc.modified += r.modified; + return acc; + }, + { total: 0, mapped: 0, upserted: 0, modified: 0 }, + ); + + const full = { ...summary, perCategory: results, dryRun }; + console.log("\nImport complete:", full); + return full; +} + +startFromCLI("medicine.job.js", runMedicineImport, "medicine"); diff --git a/backend/jobs/import/technology.job.js b/backend/jobs/import/technology.job.js new file mode 100644 index 000000000..2f0a92198 --- /dev/null +++ b/backend/jobs/import/technology.job.js @@ -0,0 +1,189 @@ +import "dotenv/config"; +import Layer from "../../models/Layer.js"; +import Event from "../../models/Event.js"; + +import buildIndustrialQuery from "../../integrations/wikidata/queries/technology/industrial.query.js"; +import buildCommunicationQuery from "../../integrations/wikidata/queries/technology/communication.query.js"; +import buildTransportQuery from "../../integrations/wikidata/queries/technology/transport.query.js"; +import buildScientificInventionQuery from "../../integrations/wikidata/queries/technology/scientificinvention.query.js"; + +import { buildEventDoc } from "../../integrations/wikidata/utils/_mapperUtils.js"; +import { startFromCLI } from "./_runImport.js"; + +const WIKIDATA_ENDPOINT = "https://query.wikidata.org/sparql"; +const USER_AGENT = + "HistoryTimelineApp/1.0 (educational project; contact: sara@example.com)"; + +async function fetchFromWikidata(sparql) { + const url = new URL(WIKIDATA_ENDPOINT); + url.searchParams.set("query", sparql); + url.searchParams.set("format", "json"); + + const response = await fetch(url.toString(), { + headers: { + Accept: "application/sparql-results+json", + "User-Agent": USER_AGENT, + }, + }); + + if (!response.ok) { + throw new Error( + `Wikidata responded with ${response.status}: ${await response.text()}`, + ); + } + + const data = await response.json(); + return data.results.bindings; +} + +function dedupeByWikidataQid(docs) { + const map = new Map(); + + for (const doc of docs) { + const qid = doc?.externalIds?.wikidataQid; + if (!qid) continue; + + const existing = map.get(qid); + if (!existing) { + map.set(qid, doc); + continue; + } + + const existingHasLocation = !!existing.location; + const docHasLocation = !!doc.location; + + if (!existingHasLocation && docHasLocation) { + map.set(qid, doc); + } + } + + return [...map.values()]; +} + +function makeFixedCategoryMapper(category) { + return (row, layerId) => buildEventDoc(row, layerId, () => category); +} + +async function importCategory({ layer, category, buildQuery, dryRun }) { + console.log(`\n→ Importing category: ${category}`); + + const sparql = buildQuery(layer.rangeStart, layer.rangeEnd); + const rows = await fetchFromWikidata(sparql); + console.log(`Wikidata returned ${rows.length} rows`); + + const mapRow = makeFixedCategoryMapper(category); + const docsRaw = rows.map((r) => mapRow(r, layer._id)).filter(Boolean); + const docs = dedupeByWikidataQid(docsRaw); + + console.log(`Deduped ${docsRaw.length} → ${docs.length} by wikidataQid`); + console.log( + `Mapped ${docs.length} valid events (${rows.length - docs.length} skipped)`, + ); + + if (dryRun) { + console.log("Sample (first 2):", JSON.stringify(docs.slice(0, 2), null, 2)); + return { + category, + total: rows.length, + mapped: docs.length, + upserted: 0, + modified: 0, + }; + } + + if (docs.length === 0) { + return { + category, + total: rows.length, + mapped: 0, + upserted: 0, + modified: 0, + }; + } + + const ops = docs.map((doc) => ({ + updateOne: { + filter: { + layerId: doc.layerId, + "externalIds.wikidataQid": doc.externalIds.wikidataQid, + }, + update: { $set: doc }, + upsert: true, + }, + })); + + const result = await Event.bulkWrite(ops, { ordered: false }); + + return { + category, + total: rows.length, + mapped: docs.length, + upserted: result.upsertedCount, + modified: result.modifiedCount, + }; +} + +export async function runTechnologyImport({ dryRun = false } = {}) { + const layer = await Layer.findOne({ + slug: "technology_inventions_europe", + }).lean(); + if (!layer) + throw new Error("Technology layer not found. Have you run the seed?"); + + console.log(`Layer: ${layer.name} (${layer._id})`); + console.log( + `Range: ${layer.rangeStart.toISOString()} → ${layer.rangeEnd.toISOString()}`, + ); + + const tasks = [ + { category: "industrial", buildQuery: buildIndustrialQuery }, + { category: "communication", buildQuery: buildCommunicationQuery }, + { category: "transport", buildQuery: buildTransportQuery }, + { + category: "scientific_invention", + buildQuery: buildScientificInventionQuery, + }, + ]; + + const results = []; + for (const t of tasks) { + try { + const r = await importCategory({ + layer, + category: t.category, + buildQuery: t.buildQuery, + dryRun, + }); + results.push(r); + } catch (err) { + console.log( + `Skipping category ${t.category} due to error: ${err.message}`, + ); + results.push({ + category: t.category, + total: 0, + mapped: 0, + upserted: 0, + modified: 0, + error: err.message, + }); + } + } + + const summary = results.reduce( + (acc, r) => { + acc.total += r.total; + acc.mapped += r.mapped; + acc.upserted += r.upserted; + acc.modified += r.modified; + return acc; + }, + { total: 0, mapped: 0, upserted: 0, modified: 0 }, + ); + + const full = { ...summary, perCategory: results, dryRun }; + console.log("\nImport complete:", full); + return full; +} + +startFromCLI("technology.job.js", runTechnologyImport, "technology"); diff --git a/backend/jobs/import/war.job.js b/backend/jobs/import/war.job.js new file mode 100644 index 000000000..dd4878955 --- /dev/null +++ b/backend/jobs/import/war.job.js @@ -0,0 +1,197 @@ +import "dotenv/config"; +import Layer from "../../models/Layer.js"; +import Event from "../../models/Event.js"; + +import buildInterstateWarsQuery from "../../integrations/wikidata/queries/war/interstateWars.query.js"; +import buildCivilWarsQuery from "../../integrations/wikidata/queries/war/civilWars.query.js"; +import buildRevolutionsUprisingsQuery from "../../integrations/wikidata/queries/war/revolutionsUprisings.query.js"; +import buildGenocidesMassViolenceQuery from "../../integrations/wikidata/queries/war/genocidesMassViolence.query.js"; +import buildMilitaryAlliancesQuery from "../../integrations/wikidata/queries/war/militaryAlliances.query.js"; + +import { buildEventDoc } from "../../integrations/wikidata/utils/_mapperUtils.js"; +import { startFromCLI } from "./_runImport.js"; + +const WIKIDATA_ENDPOINT = "https://query.wikidata.org/sparql"; +const USER_AGENT = + "HistoryTimelineApp/1.0 (educational project; contact: sara@example.com)"; + +async function fetchFromWikidata(sparql) { + const url = new URL(WIKIDATA_ENDPOINT); + url.searchParams.set("query", sparql); + url.searchParams.set("format", "json"); + + const response = await fetch(url.toString(), { + headers: { + Accept: "application/sparql-results+json", + "User-Agent": USER_AGENT, + }, + }); + + if (!response.ok) { + throw new Error( + `Wikidata responded with ${response.status}: ${await response.text()}`, + ); + } + + const data = await response.json(); + return data.results.bindings; +} + +// Helper to get rid of duplicates. In some cases, the same event is returned multiple times from Wikidata with the same QID, because of multiple types or other reasons. This function keeps only one doc per Wikidata QID, preferring docs with a location if there are duplicates. + +function dedupeByWikidataQid(docs) { + const map = new Map(); + + for (const doc of docs) { + const qid = doc?.externalIds?.wikidataQid; + if (!qid) continue; + + const existing = map.get(qid); + if (!existing) { + map.set(qid, doc); + continue; + } + + // more specific location if duplicate + const existingHasLocation = !!existing.location; + const docHasLocation = !!doc.location; + + if (!existingHasLocation && docHasLocation) { + map.set(qid, doc); + } + } + + return [...map.values()]; +} + +function makeFixedCategoryMapper(category) { + return (row, layerId) => buildEventDoc(row, layerId, () => category); +} + +async function importCategory({ layer, category, buildQuery, dryRun }) { + console.log(`\n→ Importing category: ${category}`); + + const sparql = buildQuery(layer.rangeStart, layer.rangeEnd); + const rows = await fetchFromWikidata(sparql); + console.log(`Wikidata returned ${rows.length} rows`); + + const mapRow = makeFixedCategoryMapper(category); + const docsRaw = rows.map((r) => mapRow(r, layer._id)).filter(Boolean); + const docs = dedupeByWikidataQid(docsRaw); + + console.log(`Deduped ${docsRaw.length} → ${docs.length} by wikidataQid`); + + console.log( + `Mapped ${docs.length} valid events (${rows.length - docs.length} skipped)`, + ); + + if (dryRun) { + console.log("Sample (first 2):", JSON.stringify(docs.slice(0, 2), null, 2)); + return { + category, + total: rows.length, + mapped: docs.length, + upserted: 0, + modified: 0, + }; + } + + if (docs.length === 0) { + return { + category, + total: rows.length, + mapped: 0, + upserted: 0, + modified: 0, + }; + } + + const ops = docs.map((doc) => ({ + updateOne: { + filter: { + layerId: doc.layerId, + "externalIds.wikidataQid": doc.externalIds.wikidataQid, + }, + update: { $set: doc }, + upsert: true, + }, + })); + + const result = await Event.bulkWrite(ops, { ordered: false }); + + return { + category, + total: rows.length, + mapped: docs.length, + upserted: result.upsertedCount, + modified: result.modifiedCount, + }; +} + +export async function runWarImport({ dryRun = false } = {}) { + const layer = await Layer.findOne({ + slug: "war_organized_violence_europe", + }).lean(); + if (!layer) throw new Error("War layer not found. Have you run the seed?"); + + console.log(`Layer: ${layer.name} (${layer._id})`); + console.log( + `Range: ${layer.rangeStart.toISOString()} → ${layer.rangeEnd.toISOString()}`, + ); + + const tasks = [ + { category: "interstate_wars", buildQuery: buildInterstateWarsQuery }, + { category: "civil_wars", buildQuery: buildCivilWarsQuery }, + { + category: "revolutions_uprisings", + buildQuery: buildRevolutionsUprisingsQuery, + }, + { + category: "genocides_mass_violence", + buildQuery: buildGenocidesMassViolenceQuery, + }, + { category: "military_alliances", buildQuery: buildMilitaryAlliancesQuery }, + ]; + + const results = []; + for (const t of tasks) { + try { + const r = await importCategory({ + layer, + category: t.category, + buildQuery: t.buildQuery, + dryRun, + }); + results.push(r); + } catch (err) { + console.log( + `Skipping category ${t.category} due to error: ${err.message}`, + ); + results.push({ + category: t.category, + total: 0, + mapped: 0, + upserted: 0, + modified: 0, + error: err.message, + }); + } + } + + const summary = results.reduce( + (acc, r) => { + acc.total += r.total; + acc.mapped += r.mapped; + acc.upserted += r.upserted; + acc.modified += r.modified; + return acc; + }, + { total: 0, mapped: 0, upserted: 0, modified: 0 }, + ); + + const full = { ...summary, perCategory: results, dryRun }; + console.log("\nImport complete:", full); + return full; +} + +startFromCLI("war.job.js", runWarImport, "war"); diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js new file mode 100644 index 000000000..b1b4f26be --- /dev/null +++ b/backend/middleware/auth.js @@ -0,0 +1,26 @@ +import { User } from "../models/User.js"; + +export const authenticateUser = async (req, res, next) => { + try { + const user = await User.findOne({ + accessToken: req.header("Authorization").replace("Bearer ", ""), + }); + + if (user) { + req.user = user; + next(); + } else { + res.status(401).json({ + success: false, + message: "Unauthorized: Invalid or missing access token", + loggedOut: true, + }); + } + } catch (error) { + res.status(500).json({ + success: false, + message: "Internal server error", + error: error.message, + }); + } +}; diff --git a/backend/models/Event.js b/backend/models/Event.js new file mode 100644 index 000000000..8ef418a2e --- /dev/null +++ b/backend/models/Event.js @@ -0,0 +1,50 @@ +import mongoose, { Schema } from "mongoose"; + +const SourceSchema = new Schema({ label: String, url: String }, { _id: false }); + +const WikimediaSchema = new Schema( + { imageUrl: String, credit: String, licenseUrl: String }, + { _id: false }, +); + +const EventSchema = new Schema( + { + layerId: { + type: Schema.Types.ObjectId, + ref: "Layer", + required: true, + index: true, + }, + + title: { type: String, required: true }, + summary: { type: String }, + + startDate: { type: Date, required: true, index: true }, + endDate: { type: Date }, + + category: { type: String, required: true, index: true }, + tags: { type: [String], default: [] }, + location: { type: String }, + + sources: { type: [SourceSchema], default: [] }, + wikimedia: { type: WikimediaSchema }, + externalIds: { + wikidataQid: { type: String, index: true }, + }, + lastSyncedAt: { type: Date }, + }, + { timestamps: true }, +); + +EventSchema.index({ layerId: 1, startDate: 1 }); +EventSchema.index({ layerId: 1, category: 1, startDate: 1 }); + +EventSchema.index( + { layerId: 1, "externalIds.wikidataQid": 1 }, + { + unique: true, + partialFilterExpression: { "externalIds.wikidataQid": { $type: "string" } }, + }, +); + +export default mongoose.model("Event", EventSchema); diff --git a/backend/models/Layer.js b/backend/models/Layer.js new file mode 100644 index 000000000..2dd227fab --- /dev/null +++ b/backend/models/Layer.js @@ -0,0 +1,30 @@ +import mongoose, { Schema } from "mongoose"; + +const LayerSchema = new Schema( + { + name: { type: String, required: true }, + slug: { type: String, required: true, unique: true }, + region: { + type: String, + enum: ["Europe"], + default: "Europe", + required: true, + }, + + rangeStart: { type: Date, required: true }, + rangeEnd: { type: Date, required: true }, + + categories: { + type: [String], + required: true, + }, + + isPublic: { type: Boolean, default: true }, + ownerId: { type: Schema.Types.ObjectId, ref: "User", index: true }, + }, + { timestamps: true }, +); + +LayerSchema.index({ isPublic: 1, ownerId: 1 }); + +export default mongoose.model("Layer", LayerSchema); diff --git a/backend/models/SavedTimeline.js b/backend/models/SavedTimeline.js new file mode 100644 index 000000000..2196e930f --- /dev/null +++ b/backend/models/SavedTimeline.js @@ -0,0 +1,48 @@ +import mongoose from "mongoose"; + +const SavedTimelineSchema = new mongoose.Schema( + { + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + required: true, + index: true, + }, + + name: { + type: String, + required: true, + trim: true, + maxlength: 80, + }, + + config: { + selectedLayerIds: { type: [String], default: [] }, + categoryByLayerId: { type: mongoose.Schema.Types.Mixed, default: {} }, + yearRange: { + type: [Number], + default: [1500, 2000], + validate: { + validator: (v) => + Array.isArray(v) && + v.length === 2 && + Number.isFinite(v[0]) && + Number.isFinite(v[1]) && + v[0] <= v[1], + message: "yearRange must be [start, end]", + }, + }, + }, + + notes: { + type: String, + default: "", + maxlength: 2000, + }, + }, + { timestamps: true }, +); + +SavedTimelineSchema.index({ userId: 1, createdAt: -1 }); + +export default mongoose.model("SavedTimeline", SavedTimelineSchema); diff --git a/backend/models/User.js b/backend/models/User.js new file mode 100644 index 000000000..26b70117c --- /dev/null +++ b/backend/models/User.js @@ -0,0 +1,25 @@ +import mongoose, { Schema } from "mongoose"; +import crypto from "crypto"; + +const UserSchema = new Schema( + { + email: { + type: String, + required: true, + unique: true, + trim: true, + lowercase: true, + }, + password: { + type: String, + required: true, + }, + accessToken: { + type: String, + default: () => crypto.randomBytes(128).toString("hex"), + }, + }, + { timestamps: true }, +); + +export const User = mongoose.model("User", UserSchema); diff --git a/backend/package.json b/backend/package.json index 08f29f244..0dc3bf4df 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,10 +1,12 @@ { "name": "project-final-backend", "version": "1.0.0", + "type": "module", "description": "Server part of final project", "scripts": { "start": "babel-node server.js", - "dev": "nodemon server.js --exec babel-node" + "dev": "nodemon server.js --exec babel-node", + "seed:layers": "babel-node db/seed/seedLayers.js" }, "author": "", "license": "ISC", @@ -12,9 +14,12 @@ "@babel/core": "^7.17.9", "@babel/node": "^7.16.8", "@babel/preset-env": "^7.16.11", + "bcrypt": "^6.0.0", "cors": "^2.8.5", + "dotenv": "^17.3.1", "express": "^4.17.3", - "mongoose": "^8.4.0", + "express-list-endpoints": "^7.1.1", + "mongoose": "^8.23.0", "nodemon": "^3.0.1" } -} \ No newline at end of file +} diff --git a/backend/routes/savedTimelineRoutes.js b/backend/routes/savedTimelineRoutes.js new file mode 100644 index 000000000..63b591be0 --- /dev/null +++ b/backend/routes/savedTimelineRoutes.js @@ -0,0 +1,136 @@ +import express from "express"; +import SavedTimeline from "../models/SavedTimeline.js"; +import { authenticateUser } from "../middleware/auth.js"; + +const router = express.Router(); + +router.post("/", authenticateUser, async (req, res) => { + try { + const { name, config, notes } = req.body; + + if (!name || typeof name !== "string" || !name.trim()) { + return res.status(400).json({ message: "Name is required" }); + } + + const doc = await SavedTimeline.create({ + userId: req.user._id, + name: name.trim(), + config: { + selectedLayerIds: config?.selectedLayerIds ?? [], + categoryByLayerId: config?.categoryByLayerId ?? {}, + yearRange: config?.yearRange ?? [1500, 2000], + }, + notes: typeof notes === "string" ? notes : "", + }); + + return res.status(201).json({ success: true, response: doc }); + } catch (err) { + console.error("POST /api/saved-timelines error:", err); + return res.status(500).json({ + success: false, + message: "Failed to save timeline", + error: err.message, + }); + } +}); + +router.get("/", authenticateUser, async (req, res) => { + try { + const docs = await SavedTimeline.find({ userId: req.user._id }) + .sort({ createdAt: -1 }) + .select("_id name createdAt updatedAt"); + + return res.json({ success: true, response: docs }); + } catch (err) { + console.error("GET /api/saved-timelines error:", err); + return res.status(500).json({ + success: false, + message: "Failed to fetch saved timelines", + error: err.message, + }); + } +}); + +router.get("/:id", authenticateUser, async (req, res) => { + try { + const doc = await SavedTimeline.findOne({ + _id: req.params.id, + userId: req.user._id, + }); + + if (!doc) { + return res.status(404).json({ + success: false, + message: "Saved timeline not found", + }); + } + + return res.json({ success: true, response: doc }); + } catch (err) { + console.error("GET /api/saved-timelines/:id error:", err); + return res.status(500).json({ + success: false, + message: "Failed to fetch saved timeline", + error: err.message, + }); + } +}); + +router.patch("/:id", authenticateUser, async (req, res) => { + try { + const { name, notes } = req.body; + + const update = {}; + if (typeof name === "string") update.name = name.trim(); + if (typeof notes === "string") update.notes = notes; + + const doc = await SavedTimeline.findOneAndUpdate( + { _id: req.params.id, userId: req.user._id }, + { $set: update }, + { new: true }, + ); + + if (!doc) { + return res.status(404).json({ + success: false, + message: "Saved timeline not found", + }); + } + + return res.json({ success: true, response: doc }); + } catch (err) { + console.error("PATCH /api/saved-timelines/:id error:", err); + return res.status(500).json({ + success: false, + message: "Failed to update saved timeline", + error: err.message, + }); + } +}); + +router.delete("/:id", authenticateUser, async (req, res) => { + try { + const result = await SavedTimeline.deleteOne({ + _id: req.params.id, + userId: req.user._id, + }); + + if (result.deletedCount === 0) { + return res.status(404).json({ + success: false, + message: "Saved timeline not found", + }); + } + + return res.json({ success: true }); + } catch (err) { + console.error("DELETE /api/saved-timelines/:id error:", err); + return res.status(500).json({ + success: false, + message: "Failed to delete saved timeline", + error: err.message, + }); + } +}); + +export default router; diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js new file mode 100644 index 000000000..dd541abe9 --- /dev/null +++ b/backend/routes/userRoutes.js @@ -0,0 +1,96 @@ +import express from "express"; +import bcrypt from "bcrypt"; +import { User } from "../models/User.js"; + +const router = express.Router(); + +router.post("/signup", async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ + success: false, + message: "Email, and password are required", + }); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!emailRegex.test(email)) { + return res.status(400).json({ + success: false, + message: "invalid email format", + }); + } + + const existingUser = await User.findOne({ email: email.toLowerCase() }); + + if (existingUser) { + return res.status(409).json({ + success: false, + message: "An error occurred when creating the user", + }); + } + + const salt = bcrypt.genSaltSync(); + const hashedPassword = bcrypt.hashSync(password, salt); + const user = new User({ + email: email.toLowerCase(), + password: hashedPassword, + }); + + await user.save(); + + res.status(201).json({ + success: true, + message: "User created successfully", + response: { + email: user.email, + userId: user._id, + createdAt: user.createdAt, + accessToken: user.accessToken, + }, + }); + } catch (error) { + res.status(400).json({ + success: false, + message: "Failed to create user", + response: error, + }); + } +}); + +router.post("/login", async (req, res) => { + try { + const { email, password } = req.body; + + const user = await User.findOne({ email: email.toLowerCase() }); + + if (user && bcrypt.compareSync(password, user.password)) { + res.json({ + success: true, + message: "Login successful", + response: { + email: user.email, + userId: user._id, + accessToken: user.accessToken, + }, + }); + } else { + res.status(401).json({ + success: false, + message: "Invalid email or password", + response: null, + }); + } + } catch (error) { + res.status(400).json({ + success: false, + message: "Login failed", + response: error, + }); + } +}); + +export default router; diff --git a/backend/server.js b/backend/server.js index 070c87518..2b1899d37 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,10 +1,21 @@ +import "dotenv/config"; import express from "express"; import cors from "cors"; import mongoose from "mongoose"; +import userRoutes from "./routes/userRoutes.js"; +import layersRoutes from "./api/layers/layers.routes.js"; +import SavedTimelineRoutes from "./routes/savedTimelineRoutes.js"; +import listEndPoints from "express-list-endpoints"; -const mongoUrl = process.env.MONGO_URL || "mongodb://localhost/final-project"; -mongoose.connect(mongoUrl); -mongoose.Promise = Promise; +const mongoUri = process.env.MONGO_URI; + +try { + await mongoose.connect(mongoUri); + console.log("Connected to MongoDB"); +} catch (error) { + console.error("MongoDB connection error:", error); + process.exit(1); +} const port = process.env.PORT || 8080; const app = express(); @@ -12,8 +23,18 @@ const app = express(); app.use(cors()); app.use(express.json()); -app.get("/", (req, res) => { - res.send("Hello Technigo!"); +// routes +app.use("/users", userRoutes); +app.use("/api/saved-timelines", SavedTimelineRoutes); +app.use("/api/layers", layersRoutes); + +const endpoints = listEndPoints(app); + +app.get("/", (_req, res) => { + res.json({ + message: "Hello History!", + endpoints, + }); }); // Start the server diff --git a/frontend/README.md b/frontend/README.md index 5cdb1d9cf..e6e7b932f 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,8 @@ -# Frontend part of Final Project +# Frontend -This boilerplate is designed to give you a head start in your React projects, with a focus on understanding the structure and components. As a student of Technigo, you'll find this guide helpful in navigating and utilizing the repository. +React + Vite application for the Chronos timeline interface. -## Getting Started +Run locally: -1. Install the required dependencies using `npm install`. -2. Start the development server using `npm run dev`. \ No newline at end of file +npm install +npm run dev diff --git a/frontend/index.html b/frontend/index.html index 664410b5b..ba6a7d9e1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,11 @@ - + - + + - Technigo React Vite Boiler Plate + Chronos
diff --git a/frontend/package.json b/frontend/package.json index 7b2747e94..91d5b1ea7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.90.21", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-router-dom": "^7.13.0", + "zustand": "^5.0.11" }, "devDependencies": { "@types/react": "^18.2.15", diff --git a/frontend/public/chronos.svg b/frontend/public/chronos.svg new file mode 100644 index 000000000..959138f79 --- /dev/null +++ b/frontend/public/chronos.svg @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg deleted file mode 100644 index e7b8dfb1b..000000000 --- a/frontend/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a24275e6..f054279cd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,8 +1,12 @@ -export const App = () => { +import { RouterProvider } from "react-router-dom"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { router } from "./app/router"; +import { queryClient } from "./app/queryClient"; +export default function App() { return ( - <> -

Welcome to Final Project!

- + + + ); -}; +} diff --git a/frontend/src/api/http.js b/frontend/src/api/http.js new file mode 100644 index 000000000..076275695 --- /dev/null +++ b/frontend/src/api/http.js @@ -0,0 +1,52 @@ +import { useAuthStore } from "../stores/authStore"; + +const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080"; + +async function request(path, options = {}) { + const token = useAuthStore.getState().accessToken; + + const res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); + + const data = await res.json().catch(() => null); + + if (!res.ok) { + const error = new Error(data?.message || `HTTP ${res.status}`); + error.status = res.status; + error.data = data; + throw error; + } + + return data; +} + +//Backwards compatible: http("/path") still works +export function http(path, options) { + return request(path, options); +} + +// also supports http.get/post/patch/delete +http.get = (path, options = {}) => request(path, { ...options, method: "GET" }); + +http.post = (path, body, options = {}) => + request(path, { + ...options, + method: "POST", + body: JSON.stringify(body), + }); + +http.patch = (path, body, options = {}) => + request(path, { + ...options, + method: "PATCH", + body: JSON.stringify(body), + }); + +http.delete = (path, options = {}) => + request(path, { ...options, method: "DELETE" }); diff --git a/frontend/src/api/queryKeys.js b/frontend/src/api/queryKeys.js new file mode 100644 index 000000000..778b73218 --- /dev/null +++ b/frontend/src/api/queryKeys.js @@ -0,0 +1,13 @@ +export const queryKeys = { + layers: ["layers"], + layerEvents: (layerId, params = {}) => [ + "layers", + layerId, + "events", + params.from ?? null, + params.to ?? null, + params.category ?? null, + params.tag ?? null, + ], + savedComparisons: ["savedComparisons"], +}; diff --git a/frontend/src/app/queryClient.js b/frontend/src/app/queryClient.js new file mode 100644 index 000000000..0c76a9f36 --- /dev/null +++ b/frontend/src/app/queryClient.js @@ -0,0 +1,11 @@ +import { QueryClient } from "@tanstack/react-query"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); diff --git a/frontend/src/app/router.jsx b/frontend/src/app/router.jsx new file mode 100644 index 000000000..c715a3ffc --- /dev/null +++ b/frontend/src/app/router.jsx @@ -0,0 +1,19 @@ +import { createBrowserRouter } from "react-router-dom"; +import AppLayout from "../layouts/AppLayout"; +import TimelinePage from "../features/timeline/TimelinePage"; +import LoginPage from "../pages/LoginPage"; +import RegisterPage from "../pages/RegisterPage"; +import ErrorBoundary from "../components/ErrorBoundary"; + +export const router = createBrowserRouter([ + { + path: "/", + element: , + errorElement: , + children: [ + { index: true, element: }, + { path: "login", element: }, + { path: "register", element: }, + ], + }, +]); diff --git a/frontend/src/assets/boiler-plate.svg b/frontend/src/assets/boiler-plate.svg deleted file mode 100644 index c9252833b..000000000 --- a/frontend/src/assets/boiler-plate.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9bb..000000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/assets/technigo-logo.svg b/frontend/src/assets/technigo-logo.svg deleted file mode 100644 index 3f0da3e57..000000000 --- a/frontend/src/assets/technigo-logo.svg +++ /dev/null @@ -1,31 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 000000000..412978f2f --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,13 @@ +import { useRouteError } from "react-router-dom"; + +export default function ErrorBoundary() { + const error = useRouteError(); + + return ( +
+

Oops!

+

Something went wrong.

+ {error?.message &&

{error.message}

} +
+ ); +} diff --git a/frontend/src/components/FormField.jsx b/frontend/src/components/FormField.jsx new file mode 100644 index 000000000..687b874dc --- /dev/null +++ b/frontend/src/components/FormField.jsx @@ -0,0 +1,27 @@ +const FormField = ({ + label, + name, + type = "text", + value, + onChange, + autoComplete, + error, + required = false, +}) => { + return ( + + ); +}; + +export default FormField; diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx new file mode 100644 index 000000000..69e30862a --- /dev/null +++ b/frontend/src/components/LoginForm.jsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { API_BASE_URL } from "../constants"; +import FormField from "./FormField"; + +const LoginForm = ({ handleLogin }) => { + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + + const [error, setError] = useState(""); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + + if (!formData.email || !formData.password) { + setError("Please fill in all fields"); + return; + } + + try { + const response = await fetch(`${API_BASE_URL}/users/login`, { + method: "POST", + body: JSON.stringify({ + email: formData.email, + password: formData.password, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const data = await response.json(); + + if (!response.ok || !data?.success) { + throw new Error(data?.message || "Login failed"); + } + + handleLogin(data.response); + setFormData({ email: "", password: "" }); + } catch (error) { + setError("Login failed. Please check your credentials and try again."); + console.log("Login error:", error); + } + }; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + return ( +
+

Login

+ + + + + + {error &&

{error}

} + + + + ); +}; + +export default LoginForm; diff --git a/frontend/src/components/SignupForm.jsx b/frontend/src/components/SignupForm.jsx new file mode 100644 index 000000000..4b1332a8a --- /dev/null +++ b/frontend/src/components/SignupForm.jsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { API_BASE_URL } from "../constants"; +import FormField from "./FormField"; + +const SignupForm = ({ handleLogin }) => { + const [error, setError] = useState(""); + const [fieldErrors, setFieldErrors] = useState({}); + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(""); + setFieldErrors({}); + + try { + const response = await fetch(`${API_BASE_URL}/users/signup`, { + method: "POST", + body: JSON.stringify(formData), + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok && response.status > 499) { + throw new Error("Failed to create user"); + } + + const resJson = await response.json(); + + if (!resJson.success) { + throw new Error(resJson.message || "Failed to create user"); + } + + handleLogin(resJson.response); + setFormData({ email: "", password: "" }); + } catch (error) { + const message = error.message || "Signup failed"; + + if (message.toLowerCase().includes("email")) { + setFieldErrors({ email: message }); + } else if (message.toLowerCase().includes("password")) { + setFieldErrors({ password: message }); + } else { + setError(message); + } + + console.log("Signup error:", error); + } + }; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData((prev) => ({ ...prev, [name]: value })); + }; + + return ( +
+

Sign Up

+ + + + + + + + {error &&

{error}

} + + ); +}; + +export default SignupForm; diff --git a/frontend/src/constants.js b/frontend/src/constants.js new file mode 100644 index 000000000..1b098b188 --- /dev/null +++ b/frontend/src/constants.js @@ -0,0 +1 @@ +export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; diff --git a/frontend/src/features/layers/api.js b/frontend/src/features/layers/api.js new file mode 100644 index 000000000..372378e8e --- /dev/null +++ b/frontend/src/features/layers/api.js @@ -0,0 +1,17 @@ +import { http } from "../../api/http"; + +export async function fetchLayers() { + const data = await http("/api/layers"); + return data.response; +} + +export async function fetchLayerEvents(layerId, { from, to, category } = {}) { + const params = new URLSearchParams(); + if (from) params.set("from", from); + if (to) params.set("to", to); + if (category) params.set("category", category); + + const query = params.toString() ? `?${params.toString()}` : ""; + const data = await http(`/api/layers/${layerId}/events${query}`); + return data.response; +} diff --git a/frontend/src/features/layers/hooks.js b/frontend/src/features/layers/hooks.js new file mode 100644 index 000000000..49c073761 --- /dev/null +++ b/frontend/src/features/layers/hooks.js @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { queryKeys } from "../../api/queryKeys"; +import { fetchLayers, fetchLayerEvents } from "./api"; + +export function useLayers() { + return useQuery({ + queryKey: queryKeys.layers, + queryFn: fetchLayers, + }); +} + +export function useLayerEvents(layerId, params = {}) { + return useQuery({ + queryKey: queryKeys.layerEvents(layerId, params), + queryFn: () => fetchLayerEvents(layerId, params), + enabled: !!layerId, + keepPreviousData: true, + }); +} diff --git a/frontend/src/features/timeline/TimelinePage.jsx b/frontend/src/features/timeline/TimelinePage.jsx new file mode 100644 index 000000000..a49b69579 --- /dev/null +++ b/frontend/src/features/timeline/TimelinePage.jsx @@ -0,0 +1,92 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useLayers, useLayerEvents } from "../layers/hooks"; +import TimelineRow from "./components/TimelineRow"; +import YearAxis from "./components/YearAxis"; +import EventPanel from "./components/EventPanel"; +import styles from "./styles/TimelinePage.module.css"; +import { useUiStore } from "../../stores/uiStore"; + +function ActiveLayer({ + layerId, + layers, + selectedEvent, + onEventClick, + category, +}) { + const layer = layers.find((l) => l._id === layerId); + const { data, isLoading, isError } = useLayerEvents(layerId, { + category: category || undefined, + }); + if (!layer) return null; + if (isLoading) + return

Loading {layer.name}...

; + if (isError) + return

Failed to load {layer.name}

; + return ( + + ); +} + +export default function TimelinePage() { + const { data: layersData, isLoading, isError } = useLayers(); + const layers = layersData ?? []; + const selectedLayerIds = useUiStore((s) => s.selectedLayerIds); + const categoryByLayerId = useUiStore((s) => s.categoryByLayerId); + const [startYear, endYear] = useUiStore((s) => s.yearRange); + const [selectedEvent, setSelectedEvent] = useState(null); + const rowsRef = useRef(null); + + useEffect(() => { + if (layers.length > 0 && selectedLayerIds.length === 0) { + useUiStore.setState({ selectedLayerIds: [layers[0]._id] }); + } + }, [layers, selectedLayerIds.length]); + + const handleEventClick = useCallback((event) => { + setSelectedEvent((prev) => (prev?._id === event._id ? null : event)); + }, []); + + if (isLoading) return

Loading layers...

; + if (isError) + return

Failed to load layers.

; + + return ( +
+
+

Historical Timeline

+

+ Europe · {startYear}–{endYear} +

+
+ +
+ {selectedLayerIds.length === 0 && ( +

+ Select a layer in the sidebar to begin. +

+ )} + {selectedLayerIds.map((id) => ( + + ))} + +
+ + setSelectedEvent(null)} + /> +
+ ); +} diff --git a/frontend/src/features/timeline/components/ClusterModal.jsx b/frontend/src/features/timeline/components/ClusterModal.jsx new file mode 100644 index 000000000..252d083e6 --- /dev/null +++ b/frontend/src/features/timeline/components/ClusterModal.jsx @@ -0,0 +1,44 @@ +import { useEffect } from "react"; +import styles from "../styles/ClusterModal.module.css"; + +export default function ClusterModal({ cluster, onClose, onEventClick }) { + useEffect(() => { + const handleEscape = (e) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [onClose]); + + if (!cluster) return null; + + return ( +
+
e.stopPropagation()}> +
+

{cluster.items.length} Events in Cluster

+ +
+
+ {cluster.items.map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/features/timeline/components/EventDot.jsx b/frontend/src/features/timeline/components/EventDot.jsx new file mode 100644 index 000000000..97fd146b4 --- /dev/null +++ b/frontend/src/features/timeline/components/EventDot.jsx @@ -0,0 +1,38 @@ +import { CATEGORY_COLORS, dateToPercent } from "../constants"; +import styles from "../styles/EventDot.module.css"; + +export default function EventDot({ + event, + onClick, + isSelected, + offset, + className, + title, + dataCount, + colorOverride, + leftOverride, +}) { + const color = colorOverride ?? CATEGORY_COLORS[event.category] ?? "#888"; + const left = leftOverride ?? dateToPercent(event.startDate); + + const label = title ?? event.title; + + return ( + + +
{event.category.replace(/_/g, " ")}
+ +

{event.title}

+ +
+ {formatYear(event.startDate)} + {event.endDate && ` – ${formatYear(event.endDate)}`} + {event.location && ` · ${event.location}`} +
+ + {event.summary &&

{event.summary}

} + + {event.sources?.length > 0 && ( +
+ {event.sources.map((s, i) => ( + + ↗ {s.label} + + ))} +
+ )} + + ); +} diff --git a/frontend/src/features/timeline/components/LayerSelector.jsx b/frontend/src/features/timeline/components/LayerSelector.jsx new file mode 100644 index 000000000..d51dd69e7 --- /dev/null +++ b/frontend/src/features/timeline/components/LayerSelector.jsx @@ -0,0 +1,23 @@ +import { LAYER_ACCENT } from "../constants"; +import styles from "../styles/LayerSelector.module.css"; + +export default function LayerSelector({ layers, selectedIds, onToggle }) { + return ( +
+ {layers.map((layer) => { + const active = selectedIds.includes(layer._id); + const accent = LAYER_ACCENT[layer.slug] ?? "#888"; + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/features/timeline/components/TimelineControls.jsx b/frontend/src/features/timeline/components/TimelineControls.jsx new file mode 100644 index 000000000..78b94fa29 --- /dev/null +++ b/frontend/src/features/timeline/components/TimelineControls.jsx @@ -0,0 +1,190 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useUiStore } from "../../../stores/uiStore"; +import LayerSelector from "./LayerSelector"; +import { useSavedTimelines } from "../hooks/useSavedTimelines"; +import { http } from "../../../api/http"; +import ZoomBar from "./ZoomBar"; +import styles from "../styles/TimelineControls.module.css"; + +export default function TimelineControls({ layers }) { + const selectedLayerIds = useUiStore((s) => s.selectedLayerIds); + const toggleLayer = useUiStore((s) => s.toggleLayer); + const categoryByLayerId = useUiStore((s) => s.categoryByLayerId); + const setCategoryForLayer = useUiStore((s) => s.setCategoryForLayer); + const selectedLayers = selectedLayerIds + .map((id) => layers.find((l) => l._id === id)) + .filter(Boolean); + + const setSelectedLayerIds = useUiStore((s) => s.setSelectedLayerIds); + const setCategoryByLayerId = useUiStore((s) => s.setCategoryByLayerId); + const setYearRange = useUiStore((s) => s.setYearRange); + + const { + data: timelines, + isLoading: timelinesLoading, + isError: timelinesError, + } = useSavedTimelines(); + + async function loadTimeline(id) { + try { + const res = await http.get(`/api/saved-timelines/${id}`); + const config = res.response?.config; + if (!config) return; + + setSelectedLayerIds(config.selectedLayerIds ?? []); + setCategoryByLayerId(config.categoryByLayerId ?? {}); + setYearRange(config.yearRange ?? [1500, 2000]); + } catch (err) { + console.error("Failed to load timeline:", err); + } + } + + const qc = useQueryClient(); + + const yearRange = useUiStore((s) => s.yearRange); + + const [saveName, setSaveName] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(""); + + async function saveCurrentTimeline() { + const name = saveName.trim(); + if (!name) { + setSaveError("Please enter a name."); + return; + } + + setIsSaving(true); + setSaveError(""); + + try { + await http.post("/api/saved-timelines", { + name, + config: { + selectedLayerIds, + categoryByLayerId, + yearRange, + }, + notes: "", + }); + + setSaveName(""); + // Refresh sidebar list + qc.invalidateQueries({ queryKey: ["savedTimelines"] }); + } catch (err) { + console.error("Failed to save timeline:", err); + setSaveError("Could not save timeline."); + } finally { + setIsSaving(false); + } + } + + return ( +
+
+

Layers

+ +
+ +
+

Year range

+ +
+ +
+

Categories

+ + {selectedLayers.length === 0 ? ( +

Select a layer to see category filters.

+ ) : ( +
+ {selectedLayers.map((layer) => { + const value = categoryByLayerId[layer._id] ?? ""; + return ( + + ); + })} +
+ )} +
+ +
+

Save current

+ +
+ + + setSaveName(e.target.value)} + placeholder="Name your timeline…" + autoComplete="off" + /> + + +
+ + {saveError ?

{saveError}

: null} +
+ +
+

My timelines

+ + {timelinesLoading ? ( +

Loading…

+ ) : timelinesError ? ( +

Could not load saved timelines.

+ ) : !timelines || timelines.length === 0 ? ( +

No saved timelines yet.

+ ) : ( +
    + {timelines.map((t) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/features/timeline/components/TimelineRow.jsx b/frontend/src/features/timeline/components/TimelineRow.jsx new file mode 100644 index 000000000..c93d8c74b --- /dev/null +++ b/frontend/src/features/timeline/components/TimelineRow.jsx @@ -0,0 +1,329 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; +import dotStyles from "../styles/EventDot.module.css"; +import styles from "../styles/TimelineRow.module.css"; +import { LAYER_ACCENT, CATEGORY_COLORS, dateToPercent } from "../constants"; +import EventDot from "./EventDot"; +import ClusterModal from "./ClusterModal"; +import { useUiStore } from "../../../stores/uiStore"; + +const CLUSTER_PX = 16; +const EXPLODE_DY = 32; +const EXPLODE_DX = 20; +const MAX_EXPLODE = 0; // Disable expansion on timeline, use modal instead +const CLUSTER_SEPARATION_PX = 18; +const STAGGER_DY = 10; + +function getClusterColor(items) { + const cats = new Set(items.map((x) => x.event.category)); + if (cats.size === 1) { + const cat = [...cats][0]; + return CATEGORY_COLORS[cat] ?? "#888"; + } + return "#b0b0b0"; +} + +export default function TimelineRow({ + layer, + events, + selectedEvent, + onEventClick, +}) { + const [rangeStartYear, rangeEndYear] = useUiStore((s) => s.yearRange); + const visualZoom = useUiStore((s) => s.visualZoom); + + const rangeStart = useMemo( + () => new Date(`${rangeStartYear}-01-01`), + [rangeStartYear], + ); + + const rangeEnd = useMemo( + () => new Date(`${rangeEndYear}-12-31`), + [rangeEndYear], + ); + + const visibleEvents = useMemo(() => { + if (!events?.length) return []; + return events.filter((e) => { + const d = new Date(e.startDate); + return d >= rangeStart && d <= rangeEnd; + }); + }, [events, rangeStart, rangeEnd]); + + const accent = LAYER_ACCENT[layer.slug] ?? "#888"; + + const trackRef = useRef(null); + + const [trackWidth, setTrackWidth] = useState(0); + const [explodedKey, setExplodedKey] = useState(null); + const [selectedCluster, setSelectedCluster] = useState(null); + + useEffect(() => { + if (!trackRef.current) return; + + const el = trackRef.current; + + const measure = () => { + const w = el.getBoundingClientRect().width; + setTrackWidth(Math.max(0, w - 20)); + }; + + const ro = new ResizeObserver(measure); + ro.observe(el); + measure(); + + return () => ro.disconnect(); + }, []); + + const canvasWidth = useMemo(() => { + return trackWidth ? Math.round(trackWidth * Math.max(1, visualZoom)) : 0; + }, [trackWidth, visualZoom]); + + const clusters = useMemo(() => { + if (!visibleEvents || visibleEvents.length === 0) return []; + + if (!canvasWidth) { + return visibleEvents.map((e) => { + const leftPercent = dateToPercent(e.startDate, rangeStart, rangeEnd); + return { + key: e._id, + type: "single", + leftPercent, + items: [{ event: e, leftPercent }], + }; + }); + } + + const items = visibleEvents + .map((e) => { + const leftPercent = dateToPercent(e.startDate, rangeStart, rangeEnd); + const xPx = (leftPercent / 100) * canvasWidth; + + return { event: e, leftPercent, xPx }; + }) + .sort((a, b) => a.xPx - b.xPx); + + const groups = []; + + let group = [items[0]]; + let groupStartX = items[0].xPx; + + for (let i = 1; i < items.length; i++) { + const curr = items[i]; + + if (curr.xPx - groupStartX <= CLUSTER_PX) { + group.push(curr); + } else { + groups.push(group); + group = [curr]; + groupStartX = curr.xPx; + } + } + + groups.push(group); + + return groups + .map((g, idx) => { + const leftAvgPx = g.reduce((sum, it) => sum + it.xPx, 0) / g.length; + + const leftPercent = (leftAvgPx / canvasWidth) * 100; + + const key = `${layer._id}-${idx}-${Math.round(leftAvgPx)}-${g.length}`; + + return { + key, + type: g.length === 1 ? "single" : "cluster", + leftPercent, + leftAvgPx, + items: g, + }; + }) + .map((c, i, arr) => { + if (c.type !== "cluster") return { ...c, staggerDy: 0 }; + + const prev = arr[i - 1]; + + if (prev && prev.type === "cluster") { + const dx = Math.abs(c.leftAvgPx - prev.leftAvgPx); + + if (dx < CLUSTER_SEPARATION_PX) { + const dir = i % 2 === 0 ? -1 : 1; + + return { ...c, staggerDy: dir * STAGGER_DY }; + } + } + + return { ...c, staggerDy: 0 }; + }); + }, [visibleEvents, canvasWidth, layer._id, rangeStart, rangeEnd]); + + useEffect(() => { + if (!explodedKey) return; + + const stillExists = clusters.some( + (c) => c.key === explodedKey && c.type === "cluster", + ); + + if (!stillExists) setExplodedKey(null); + }, [clusters, explodedKey]); + + const toggleExplode = useCallback((key) => { + setExplodedKey((prev) => (prev === key ? null : key)); + }, []); + + const openClusterModal = useCallback((cluster) => { + setSelectedCluster(cluster); + }, []); + + const closeClusterModal = useCallback(() => { + setSelectedCluster(null); + }, []); + + useEffect(() => { + if (!explodedKey) return; + + const onDocMouseDown = (e) => { + const trackEl = trackRef.current; + + if (!trackEl) return; + + if (!trackEl.contains(e.target)) { + setExplodedKey(null); + } + }; + + document.addEventListener("mousedown", onDocMouseDown); + + return () => document.removeEventListener("mousedown", onDocMouseDown); + }, [explodedKey]); + + return ( +
+
+ {layer.name} + + + {visibleEvents.length} / {events.length} events + +
+ +
+
+
+ + {clusters.map((c) => { + if (c.type === "single") { + const it = c.items[0]; + + return ( + + ); + } + + const isExploded = explodedKey === c.key; + + const clusterColor = getClusterColor(c.items); + + const count = c.items.length; + + const explodedItems = c.items + .slice() + .sort((a, b) => a.xPx - b.xPx) + .slice(0, MAX_EXPLODE); + + const hiddenCount = Math.max(0, count - explodedItems.length); + + return ( +
+ openClusterModal(c)} + isSelected={false} + title={`${count} events (click to view list)`} + dataCount={count} + colorOverride={clusterColor} + leftOverride={c.leftPercent} + offset={{ dx: 0, dy: c.staggerDy ?? 0 }} + className={dotStyles.cluster} + /> + + {isExploded && + explodedItems.map((it, i) => { + const sameX = explodedItems.filter( + (x) => Math.abs(x.xPx - it.xPx) < 0.5, + ); + + let dx = 0; + + if (sameX.length > 1) { + const j = sameX.findIndex( + (x) => x.event._id === it.event._id, + ); + + const mid = (sameX.length - 1) / 2; + + dx = Math.round((j - mid) * EXPLODE_DX); + } + + const baseDy = c.staggerDy ?? 0; + + const dy = + baseDy + (i % 2 === 0 ? -EXPLODE_DY : EXPLODE_DY); + + return ( + + ); + })} + + {isExploded && hiddenCount > 0 && ( + {}} + isSelected={false} + title={`+${hiddenCount} more`} + dataCount={`+${hiddenCount}`} + colorOverride={"#777"} + leftOverride={c.leftPercent} + offset={{ dx: 0, dy: -28 }} + className={dotStyles.cluster} + /> + )} +
+ ); + })} +
+ + +
+
+ ); +} diff --git a/frontend/src/features/timeline/components/YearAxis.jsx b/frontend/src/features/timeline/components/YearAxis.jsx new file mode 100644 index 000000000..d06cee052 --- /dev/null +++ b/frontend/src/features/timeline/components/YearAxis.jsx @@ -0,0 +1,72 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useUiStore } from "../../../stores/uiStore"; +import styles from "../styles/YearAxis.module.css"; + +export default function YearAxis() { + const [startYear, endYear] = useUiStore((s) => s.yearRange); + const visualZoom = useUiStore((s) => s.visualZoom); + + const axisRef = useRef(null); + const [axisWidth, setAxisWidth] = useState(0); + + useEffect(() => { + if (!axisRef.current) return; + const el = axisRef.current; + + const measure = () => + setAxisWidth(Math.max(0, el.getBoundingClientRect().width)); + const ro = new ResizeObserver(measure); + ro.observe(el); + measure(); + + return () => ro.disconnect(); + }, []); + + const span = Math.max(1, endYear - startYear); + + const ticks = useMemo(() => { + const step = span <= 50 ? 10 : span <= 200 ? 25 : span <= 500 ? 50 : 100; + const first = Math.ceil(startYear / step) * step; + + const arr = []; + for (let y = first; y <= endYear; y += step) arr.push(y); + return arr; + }, [startYear, endYear, span]); + + const canvasWidth = axisWidth + ? Math.round(axisWidth * Math.max(1, visualZoom)) + : 0; + + return ( +
+
+ {ticks.map((y, index) => { + const leftPx = ((y - startYear) / span) * canvasWidth; + const isFirst = index === 0; + const isLast = index === ticks.length - 1; + + let transform = "translateX(-50%)"; + if (isFirst) transform = "translateX(0)"; + if (isLast) transform = "translateX(-100%)"; + + return ( +
+
+ {y} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/features/timeline/components/ZoomBar.jsx b/frontend/src/features/timeline/components/ZoomBar.jsx new file mode 100644 index 000000000..534a27bb5 --- /dev/null +++ b/frontend/src/features/timeline/components/ZoomBar.jsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from "react"; +import styles from "../styles/ZoomBar.module.css"; +import { useUiStore } from "../../../stores/uiStore"; + +function clampYear(n, min, max) { + if (Number.isNaN(n)) return min; + return Math.min(max, Math.max(min, n)); +} + +export default function ZoomBar({ minYear = 1500, maxYear = 2000 }) { + const [start, end] = useUiStore((s) => s.yearRange); + const setYearRange = useUiStore((s) => s.setYearRange); + const resetYearRange = useUiStore((s) => s.resetYearRange); + const visualZoom = useUiStore((s) => s.visualZoom); + const setVisualZoom = useUiStore((s) => s.setVisualZoom); + const resetVisualZoom = useUiStore((s) => s.resetVisualZoom); + + // draft strings so typing feels normal + const [draftStart, setDraftStart] = useState(String(start)); + const [draftEnd, setDraftEnd] = useState(String(end)); + + // keep drafts in sync when store changes (e.g. Reset) + useEffect(() => { + setDraftStart(String(start)); + setDraftEnd(String(end)); + }, [start, end]); + + const commit = (nextStartStr, nextEndStr) => { + const nextStart = clampYear(Number(nextStartStr), minYear, maxYear); + const nextEnd = clampYear(Number(nextEndStr), minYear, maxYear); + + // keep order + const s = Math.min(nextStart, nextEnd); + const e = Math.max(nextStart, nextEnd); + + setYearRange([s, e]); + }; + + return ( +
+
+ + + +
+ + + + +
+ ); +} diff --git a/frontend/src/features/timeline/constants.js b/frontend/src/features/timeline/constants.js new file mode 100644 index 000000000..56a3d19a5 --- /dev/null +++ b/frontend/src/features/timeline/constants.js @@ -0,0 +1,44 @@ +export const CATEGORY_COLORS = { + // War + interstate_wars: "#c0392b", + civil_wars: "#e67e22", + revolutions_uprisings: "#f1c40f", + genocides_mass_violence: "#8e1a1a", + military_alliances: "#3498db", + // Medicine + major_epidemics_pandemics: "#9b59b6", + vaccines: "#27ae60", + medical_breakthroughs: "#1abc9c", + public_health_reforms: "#16a085", + hospital_systems: "#2980b9", + germ_theory_bacteriology: "#6c3483", +}; + +export const LAYER_ACCENT = { + war_organized_violence_europe: "#c0392b", + medicine_disease_europe: "#9b59b6", +}; + +export const RANGE_START = new Date("1500-01-01").getTime(); +export const RANGE_END = new Date("2001-01-01").getTime(); +export const RANGE_SPAN = RANGE_END - RANGE_START; + +export function dateToPercent( + date, + rangeStart = RANGE_START, + rangeEnd = RANGE_END, +) { + const t = new Date(date).getTime(); + const start = rangeStart instanceof Date ? rangeStart.getTime() : rangeStart; + const end = rangeEnd instanceof Date ? rangeEnd.getTime() : rangeEnd; + + const span = end - start; + if (!Number.isFinite(t) || span <= 0) return 0; + + const p = ((t - start) / span) * 100; + return Math.max(0, Math.min(100, p)); +} + +export function formatYear(date) { + return new Date(date).getFullYear(); +} diff --git a/frontend/src/features/timeline/hooks/useSavedTimelines.js b/frontend/src/features/timeline/hooks/useSavedTimelines.js new file mode 100644 index 000000000..217a73017 --- /dev/null +++ b/frontend/src/features/timeline/hooks/useSavedTimelines.js @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; +import { http } from "../../../api/http"; + +export function useSavedTimelines() { + return useQuery({ + queryKey: ["savedTimelines"], + queryFn: async () => { + const res = await http.get("/api/saved-timelines"); + return res.response; + }, + }); +} + +export function useSavedTimeline(id) { + return useQuery({ + queryKey: ["savedTimelines", id], + queryFn: async () => { + const res = await http.get(`/api/saved-timelines/${id}`); + return res.response; + }, + enabled: !!id, + }); +} diff --git a/frontend/src/features/timeline/styles/ClusterModal.module.css b/frontend/src/features/timeline/styles/ClusterModal.module.css new file mode 100644 index 000000000..098516e14 --- /dev/null +++ b/frontend/src/features/timeline/styles/ClusterModal.module.css @@ -0,0 +1,93 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: white; + border-radius: 8px; + max-width: 500px; + max-height: 70vh; + width: 90%; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + display: flex; + flex-direction: column; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #eee; +} + +.header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.closeBtn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.closeBtn:hover { + background: #f0f0f0; +} + +.list { + flex: 1; + overflow-y: auto; + padding: 0; +} + +.eventItem { + display: block; + width: 100%; + padding: 12px 20px; + border: none; + background: none; + text-align: left; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; + transition: background 0.1s; +} + +.eventItem:hover { + background: #f8f8f8; +} + +.eventItem:last-child { + border-bottom: none; +} + +.title { + font-weight: 500; + color: #333; + display: block; +} + +.date { + font-size: 14px; + color: #666; + margin-top: 2px; +} \ No newline at end of file diff --git a/frontend/src/features/timeline/styles/EventDot.module.css b/frontend/src/features/timeline/styles/EventDot.module.css new file mode 100644 index 000000000..73d049dce --- /dev/null +++ b/frontend/src/features/timeline/styles/EventDot.module.css @@ -0,0 +1,90 @@ +.dot { + appearance: none; + -webkit-appearance: none; + border: none; + padding: 0; + margin: 0; + background: transparent; + font: inherit; + line-height: 1; + cursor: pointer; + position: absolute; + left: var(--left); + top: 50%; + transform: translate(-50%, -50%) translate(var(--dx, 0px), var(--dy, 0px)); + width: 28px; + height: 28px; + border-radius: 50%; + z-index: 1; +} + +.dot::before { + content: ""; + position: absolute; + inset: 50% auto auto 50%; + transform: translate(-50%, -50%); + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color); + border: 1px solid rgba(255, 255, 255, 0.15); + transition: + width 0.15s ease, + height 0.15s ease, + box-shadow 0.15s ease, + transform 0.15s ease; +} + +.dot:hover::before { + width: 13px; + height: 13px; +} + +.dot:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.95); + outline-offset: 2px; +} + +.selected::before { + width: 14px; + height: 14px; + border: 2px solid #fff; + box-shadow: 0 0 8px var(--color); +} + +.cluster { + z-index: 5; +} + +.cluster::before { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.35); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.35); +} + +.cluster::after { + content: attr(data-count); + position: absolute; + top: -10px; + left: 50%; + transform: translateX(-50%); + font-size: 12px; + line-height: 1; + padding: 3px 7px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.98); + color: #111; + border: 1px solid rgba(0, 0, 0, 0.25); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); + pointer-events: none; + white-space: nowrap; +} + +.trackHover .dot::before { + filter: brightness(1.1); +} + +.trackHover .cluster::before { + filter: brightness(1.05); +} diff --git a/frontend/src/features/timeline/styles/EventPanel.module.css b/frontend/src/features/timeline/styles/EventPanel.module.css new file mode 100644 index 000000000..044bb9c90 --- /dev/null +++ b/frontend/src/features/timeline/styles/EventPanel.module.css @@ -0,0 +1,79 @@ +.panel { + position: fixed; + right: 2rem; + top: 50%; + transform: translateY(-50%); + width: 320px; + background: #0f0f1a; + border: 1px solid color-mix(in srgb, var(--color) 25%, transparent); + border-left: 3px solid var(--color); + padding: 1.75rem; + z-index: 100; + box-shadow: 0 0 60px rgba(0, 0, 0, 0.7); +} + +.closeBtn { + position: absolute; + top: 0.75rem; + right: 0.75rem; + background: none; + border: none; + color: #444; + cursor: pointer; + font-size: 0.9rem; + transition: color 0.15s ease; + padding: 0; +} + +.closeBtn:hover { + color: #e8e4d9; +} + +.category { + font-size: 0.65rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: var(--color); + margin-bottom: 0.5rem; + font-family: "JetBrains Mono", monospace; +} + +.title { + margin: 0 0 0.5rem; + font-family: "Cormorant Garamond", serif; + font-size: 1.1rem; + font-weight: 400; + line-height: 1.3; + color: #e8e4d9; +} + +.meta { + font-size: 0.75rem; + color: #555; + margin-bottom: 1rem; + font-family: "JetBrains Mono", monospace; +} + +.summary { + font-size: 0.85rem; + color: #888; + line-height: 1.7; + margin: 0 0 1.25rem; +} + +.sources { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.sourceLink { + font-size: 0.75rem; + color: var(--color); + text-decoration: none; + transition: opacity 0.15s ease; +} + +.sourceLink:hover { + opacity: 0.7; +} diff --git a/frontend/src/features/timeline/styles/LayerSelector.module.css b/frontend/src/features/timeline/styles/LayerSelector.module.css new file mode 100644 index 000000000..fda0e7584 --- /dev/null +++ b/frontend/src/features/timeline/styles/LayerSelector.module.css @@ -0,0 +1,31 @@ +.wrapper { + display: flex; + gap: 0.75rem; + margin-bottom: 2.5rem; + flex-wrap: wrap; +} + +.btn { + padding: 0.4rem 1.25rem; + background: transparent; + border: 1px solid #2a2a3e; + color: #444; + border-radius: 2px; + cursor: pointer; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + transition: all 0.15s ease; +} + +.btn:hover { + border-color: var(--accent); + color: var(--accent); +} + +.active { + background: color-mix(in srgb, var(--accent) 15%, transparent); + border-color: var(--accent); + color: var(--accent); +} diff --git a/frontend/src/features/timeline/styles/TimelineControls.module.css b/frontend/src/features/timeline/styles/TimelineControls.module.css new file mode 100644 index 000000000..267f51e01 --- /dev/null +++ b/frontend/src/features/timeline/styles/TimelineControls.module.css @@ -0,0 +1,163 @@ +.panel { + display: grid; + gap: 1rem; +} + +.block { + border: 1px solid #1a1a2e; + background: #0f0f1a; + border-radius: 10px; + padding: 0.75rem; +} + +.subheading { + margin: 0 0 0.5rem; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + color: rgba(232, 228, 217, 0.7); +} + +.hint { + margin: 0; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: rgba(232, 228, 217, 0.6); +} + +.categoryList { + display: grid; + gap: 0.75rem; +} + +.categoryRow { + display: grid; + gap: 0.4rem; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: rgba(232, 228, 217, 0.8); +} + +.categoryName { + color: rgba(232, 228, 217, 0.75); +} + +.select { + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: #e8e4d9; + padding: 0.5rem 0.65rem; + border-radius: 6px; + max-width: 100%; +} + +.select:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +.savedList { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.5rem; +} + +.savedItem { + margin: 0; +} + +.savedButton { + width: 100%; + text-align: left; + display: block; + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: rgba(232, 228, 217, 0.9); + padding: 0.55rem 0.65rem; + border-radius: 8px; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.savedButton:hover { + border-color: rgba(232, 228, 217, 0.25); +} + +.savedButton:active { + transform: translateY(1px); +} + +.savedButton:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +/* Save current timeline */ +.saveRow { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.5rem; + align-items: center; +} + +.input { + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: #e8e4d9; + padding: 0.55rem 0.65rem; + border-radius: 8px; + max-width: 100%; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; +} + +.input:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +.saveButton { + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: rgba(232, 228, 217, 0.9); + padding: 0.55rem 0.75rem; + border-radius: 8px; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + cursor: pointer; + white-space: nowrap; +} + +.saveButton:hover { + border-color: rgba(232, 228, 217, 0.25); +} + +.saveButton:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +.saveButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Screen-reader only label */ +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/frontend/src/features/timeline/styles/TimelinePage.module.css b/frontend/src/features/timeline/styles/TimelinePage.module.css new file mode 100644 index 000000000..58b3124a4 --- /dev/null +++ b/frontend/src/features/timeline/styles/TimelinePage.module.css @@ -0,0 +1,53 @@ +.page { + padding: 1rem 1.5rem 2rem 0; + min-width: 0; +} + +.safeWrap { + min-width: 0; + overflow: hidden; +} + +.header { + margin-bottom: 1.5rem; +} + +.title { + font-family: "Cormorant Garamond", serif; + font-size: 2rem; + font-weight: 300; + color: #f2eee4; + margin: 0 0 0.25rem; +} + +.subtitle { + font-size: 0.75rem; + letter-spacing: 0.1em; + color: rgba(232, 228, 217, 0.82); + font-family: "JetBrains Mono", monospace; + margin: 0; +} + +.timeline { + display: flex; + flex-direction: column; + gap: 1rem; + overflow-x: auto; + overflow-y: visible; + scroll-behavior: auto; + min-width: 0; +} + +.status { + font-size: 0.8rem; + color: rgba(232, 228, 217, 0.82); + font-family: "JetBrains Mono", monospace; + padding: 1rem 0; +} + +.statusError { + font-size: 0.8rem; + color: #ff7b6b; + font-family: "JetBrains Mono", monospace; + padding: 1rem 0; +} diff --git a/frontend/src/features/timeline/styles/TimelineRow.module.css b/frontend/src/features/timeline/styles/TimelineRow.module.css new file mode 100644 index 000000000..34f1cabab --- /dev/null +++ b/frontend/src/features/timeline/styles/TimelineRow.module.css @@ -0,0 +1,79 @@ +.wrapper { + margin-bottom: 2.25rem; + position: relative; +} + +.label { + font-size: 0.7rem; + letter-spacing: 0.15em; + text-transform: uppercase; + color: color-mix(in srgb, var(--accent) 70%, white); + margin-bottom: 0.5rem; + font-family: "JetBrains Mono", monospace; + display: flex; + align-items: center; + gap: 0.75rem; + text-shadow: 0 0 6px color-mix(in srgb, var(--accent) 40%, transparent); +} + +.count { + color: rgba(232, 228, 217, 0.82); +} + +.track { + position: relative; + height: 110px; + padding: 12px 10px; + background: #0f0f1a; + border: 1px solid #24243a; + border-radius: 4px; + overflow-x: visible; + overflow-y: visible; + scroll-behavior: auto; +} + +.canvas { + position: relative; + height: 100%; + min-width: 100%; +} + +.centerLine { + position: absolute; + top: 50%; + left: 10px; + right: 10px; + height: 1px; + background: rgba(232, 228, 217, 0.18); + transform: translateY(-1px); +} + +.wrapper::before { + content: ""; + position: absolute; + inset: -6px -10px; + border-radius: 8px; + background: radial-gradient( + circle at 50% 50%, + color-mix(in srgb, var(--accent) 18%, transparent), + transparent 70% + ); + opacity: 0.25; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.wrapper:hover::before { + opacity: 0.45; +} + +.wrapper:hover .track { + border-color: color-mix(in srgb, var(--accent) 35%, #24243a); + box-shadow: + 0 0 0 1px rgba(232, 228, 217, 0.08), + 0 0 18px rgba(0, 0, 0, 0.35); +} + +.wrapper:hover .centerLine { + background: rgba(232, 228, 217, 0.24); +} diff --git a/frontend/src/features/timeline/styles/YearAxis.module.css b/frontend/src/features/timeline/styles/YearAxis.module.css new file mode 100644 index 000000000..769731480 --- /dev/null +++ b/frontend/src/features/timeline/styles/YearAxis.module.css @@ -0,0 +1,35 @@ +.axis { + position: relative; + height: 32px; + margin-top: 0.75rem; + overflow-x: hidden; + overflow-y: hidden; + scroll-behavior: auto; +} + +.canvas { + position: relative; + height: 100%; + min-width: 100%; +} + +.tick { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + user-select: none; +} + +.line { + width: 1px; + height: 6px; + background: rgba(232, 228, 217, 0.45); +} + +.label { + font-size: 0.65rem; + color: rgba(232, 228, 217, 0.86); + font-family: "JetBrains Mono", monospace; +} diff --git a/frontend/src/features/timeline/styles/ZoomBar.module.css b/frontend/src/features/timeline/styles/ZoomBar.module.css new file mode 100644 index 000000000..44a611b0f --- /dev/null +++ b/frontend/src/features/timeline/styles/ZoomBar.module.css @@ -0,0 +1,93 @@ +.bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin: 0.75rem 0 1rem; + flex-wrap: wrap; + min-width: 0; +} + +.group { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.label { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: #b8b8b8; +} + +.input { + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: #e8e4d9; + font-size: 0.95rem; + caret-color: #e8e4d9; + padding: 0.45rem 0.6rem; + border-radius: 6px; + width: 7.5rem; + max-width: 100%; +} + +.input:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +.reset { + background: transparent; + border: 1px solid #1a1a2e; + color: #e8e4d9; + padding: 0.45rem 0.75rem; + border-radius: 999px; + cursor: pointer; +} + +.reset:focus-visible { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +@media (max-width: 600px) { + .input { + width: 100%; + } +} + +.zoomLabel { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: #b8b8b8; + min-width: 0; +} + +.zoomSlider { + width: 10rem; + max-width: 100%; +} + +.zoomValue { + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: rgba(232, 228, 217, 0.7); + white-space: nowrap; +} + +@media (max-width: 600px) { + .zoomLabel { + width: 100%; + flex-wrap: wrap; + } + .zoomSlider { + width: 100%; + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index e69de29bb..d6f4da9e9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -0,0 +1,38 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + font-family: system-ui, sans-serif; + background: #05060a; + color: #e8e4d9; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { + color: inherit; + text-decoration: none; +} + +a:focus, +button:focus, +select:focus, +input:focus { + outline: 2px solid #7aa2f7; + outline-offset: 2px; +} + +img { + max-width: 100%; + display: block; +} diff --git a/frontend/src/layouts/AppLayout.jsx b/frontend/src/layouts/AppLayout.jsx new file mode 100644 index 000000000..8c7d56d57 --- /dev/null +++ b/frontend/src/layouts/AppLayout.jsx @@ -0,0 +1,118 @@ +import { useEffect, useRef, useState } from "react"; +import { Outlet, Link } from "react-router-dom"; +import { useLayers } from "../features/layers/hooks"; +import TimelineControls from "../features/timeline/components/TimelineControls"; +import { useAuthStore } from "../stores/authStore"; +import styles from "./AppLayout.module.css"; + +export default function AppLayout() { + const { user, logout, isAuthenticated } = useAuthStore(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const { data: layersData } = useLayers(); + const layers = layersData ?? []; + const burgerRef = useRef(null); + + const closeSidebar = () => setIsSidebarOpen(false); + + useEffect(() => { + if (!isSidebarOpen) return; + + const handleKeyDown = (event) => { + if (event.key === "Escape") { + closeSidebar(); + burgerRef.current?.focus(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isSidebarOpen]); + + return ( +
+
+ + + + Chronos + + +
+ {isAuthenticated() ? ( + <> + {user?.email} + + + ) : ( + <> + + Log in + + + + Register + + + )} +
+
+ + {isSidebarOpen && ( + <> +
+ ); +} diff --git a/frontend/src/layouts/AppLayout.module.css b/frontend/src/layouts/AppLayout.module.css new file mode 100644 index 000000000..3c8d8823b --- /dev/null +++ b/frontend/src/layouts/AppLayout.module.css @@ -0,0 +1,180 @@ +@import url("https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400;500&family=JetBrains+Mono:wght@300;400&display=swap"); + +.root { + min-height: 100vh; + background: #0a0a0f; + color: #e8e4d9; + font-family: "JetBrains Mono", monospace; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + height: 72px; + box-sizing: border-box; + gap: 1rem; + padding: 1.25rem 2.5rem; + border-bottom: 1px solid #1a1a2e; +} + +.logo { + font-family: "Cormorant Garamond", serif; + font-size: 1.3rem; + font-weight: 300; + letter-spacing: 0.2em; + color: #f2eee4; + text-decoration: none; + text-transform: uppercase; +} + +.topbarRight { + display: flex; + gap: 1.5rem; + align-items: center; +} + +.navLink { + color: #d0cbc0; + text-decoration: none; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + transition: + color 0.15s ease, + opacity 0.15s ease; +} + +.navLink:hover { + color: #f2eee4; +} + +.userEmail { + color: #c6c1b6; + font-size: 0.75rem; +} + +.logoutBtn { + background: none; + border: none; + color: #d0cbc0; + cursor: pointer; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + letter-spacing: 0.1em; + text-transform: uppercase; + transition: + color 0.15s ease, + opacity 0.15s ease; + padding: 0; +} + +.logoutBtn:hover { + color: #f2eee4; +} + +.shell { + min-height: calc(100vh - 72px); +} + +.sidebar { + position: fixed; + top: 72px; + left: 0; + height: calc(100vh - 72px); + width: 340px; + border-right: 1px solid #24243a; + padding: 1.25rem; + box-sizing: border-box; + background: #0a0a0f; + transform: translateX(-110%); + transition: transform 0.2s ease; + z-index: 50; + overflow-y: auto; +} + +.sidebarOpen { + transform: translateX(0); +} + +.sideNav { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sideLink { + color: #d6d1c6; + text-decoration: none; + font-size: 0.85rem; + letter-spacing: 0.05em; +} + +.sideLink:hover { + color: #f2eee4; +} + +.main { + width: 100%; + padding: 1rem; + min-width: 0; +} + +.burger { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid #3a3a57; + color: #f2eee4; + border-radius: 8px; + padding: 0.35rem 0.6rem; + cursor: pointer; + line-height: 1; +} + +.overlay { + display: block; + position: fixed; + inset: 72px 0 0 0; + background: rgba(0, 0, 0, 0.72); + border: none; + z-index: 40; +} + +.sidebarContent { + margin-top: 1rem; + padding-bottom: 2rem; +} + +.logo:focus-visible, +.navLink:focus-visible, +.logoutBtn:focus-visible, +.sideLink:focus-visible, +.burger:focus-visible { + outline: 2px solid #f2eee4; + outline-offset: 3px; + border-radius: 8px; +} + +@media (max-width: 900px) { + .topbar { + height: 64px; + padding: 1rem; + box-sizing: border-box; + } + + .topbarRight { + gap: 1rem; + } + + .sidebar { + top: 64px; + height: calc(100vh - 64px); + width: 280px; + } + + .overlay { + inset: 64px 0 0 0; + } +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 51294f399..c99005330 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,10 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { App } from "./App.jsx"; +import App from "./App.jsx"; import "./index.css"; ReactDOM.createRoot(document.getElementById("root")).render( - + , ); diff --git a/frontend/src/pages/AuthPage.module.css b/frontend/src/pages/AuthPage.module.css new file mode 100644 index 000000000..461cdaefa --- /dev/null +++ b/frontend/src/pages/AuthPage.module.css @@ -0,0 +1,84 @@ +.page { + min-height: calc(100vh - 64px); + display: grid; + place-items: start center; + padding: 2rem 1rem; +} + +.card { + width: 100%; + max-width: 520px; + border: 1px solid #1a1a2e; + background: #0f0f1a; + border-radius: 12px; + padding: 1.25rem; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35); +} + +.title { + margin: 0 0 1rem; + font-family: "JetBrains Mono", monospace; + font-size: 1.5rem; + letter-spacing: 0.06em; + color: rgba(232, 228, 217, 0.95); +} + +.form :global(h2) { + /* hide the form’s internal h2 there no double headings */ + display: none; +} + +.form :global(form) { + display: grid; + gap: 0.75rem; +} + +.form :global(label) { + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: rgba(232, 228, 217, 0.8); +} + +.form :global(input) { + width: 100%; + background: #0a0a0f; + border: 1px solid #1a1a2e; + color: #e8e4d9; + padding: 0.6rem 0.7rem; + border-radius: 8px; + font-family: "JetBrains Mono", monospace; + font-size: 0.9rem; +} + +.form :global(input:focus-visible) { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +.form :global(button[type="submit"]) { + justify-self: start; + background: #0a0a0f; + border: 1px solid rgba(232, 228, 217, 0.25); + color: rgba(232, 228, 217, 0.95); + padding: 0.55rem 0.85rem; + border-radius: 8px; + font-family: "JetBrains Mono", monospace; + font-size: 0.8rem; + cursor: pointer; +} + +.form :global(button[type="submit"]:hover) { + border-color: rgba(232, 228, 217, 0.45); +} + +.form :global(button[type="submit"]:focus-visible) { + outline: 3px solid rgba(232, 228, 217, 0.9); + outline-offset: 2px; +} + +.form :global(p) { + margin: 0; + font-family: "JetBrains Mono", monospace; + font-size: 0.75rem; + color: rgba(232, 228, 217, 0.7); +} diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx new file mode 100644 index 000000000..7f3193924 --- /dev/null +++ b/frontend/src/pages/LoginPage.jsx @@ -0,0 +1,25 @@ +import { useNavigate } from "react-router-dom"; +import LoginForm from "../components/LoginForm"; +import { useAuthStore } from "../stores/authStore"; +import styles from "./AuthPage.module.css"; + +export default function LoginPage() { + const navigate = useNavigate(); + const setAuth = useAuthStore((s) => s.setAuth); + + function handleLogin(payload) { + setAuth(payload); + navigate("/"); + } + + return ( +
+
+

Log in

+
+ +
+
+
+ ); +} diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx new file mode 100644 index 000000000..ca841b8dc --- /dev/null +++ b/frontend/src/pages/RegisterPage.jsx @@ -0,0 +1,25 @@ +import { useNavigate } from "react-router-dom"; +import SignupForm from "../components/SignupForm"; +import { useAuthStore } from "../stores/authStore"; +import styles from "./AuthPage.module.css"; + +export default function RegisterPage() { + const navigate = useNavigate(); + const setAuth = useAuthStore((s) => s.setAuth); + + function handleLogin(payload) { + setAuth(payload); + navigate("/"); + } + + return ( +
+
+

Register

+
+ +
+
+
+ ); +} diff --git a/frontend/src/stores/authStore.js b/frontend/src/stores/authStore.js new file mode 100644 index 000000000..05e4f9eb1 --- /dev/null +++ b/frontend/src/stores/authStore.js @@ -0,0 +1,33 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export const useAuthStore = create( + persist( + (set, get) => ({ + user: null, + accessToken: null, + + isAuthenticated: () => !!get().accessToken, + + setAuth: (payload) => + set({ + user: payload + ? { + email: payload.email ?? null, + userId: payload.userId ?? null, + } + : null, + accessToken: payload?.accessToken ?? null, + }), + + logout: () => + set({ + user: null, + accessToken: null, + }), + }), + { + name: "auth", + }, + ), +); diff --git a/frontend/src/stores/uiStore.js b/frontend/src/stores/uiStore.js new file mode 100644 index 000000000..b5a7139d9 --- /dev/null +++ b/frontend/src/stores/uiStore.js @@ -0,0 +1,74 @@ +import { create } from "zustand"; + +const DEFAULT_RANGE = [1500, 2000]; + +export const useUiStore = create((set, get) => ({ + // Zoom + yearRange: DEFAULT_RANGE, + setYearRange: (range) => set({ yearRange: range }), + resetYearRange: () => set({ yearRange: DEFAULT_RANGE }), + visualZoom: 1, + setVisualZoom: (z) => set({ visualZoom: z }), + resetVisualZoom: () => set({ visualZoom: 1 }), + + // Layer selection (max 2) + selectedLayerIds: [], + + toggleLayer: (id) => { + const prev = get().selectedLayerIds; + + // remove + if (prev.includes(id)) { + set((state) => { + const nextSelected = state.selectedLayerIds.filter((x) => x !== id); + const nextCats = { ...state.categoryByLayerId }; + delete nextCats[id]; + return { selectedLayerIds: nextSelected, categoryByLayerId: nextCats }; + }); + return; + } + + // add (max 2) + if (prev.length >= 2) { + const nextSelected = [...prev.slice(1), id]; + + const removedId = prev[0]; + set((state) => { + const nextCats = { ...state.categoryByLayerId }; + delete nextCats[removedId]; + return { selectedLayerIds: nextSelected, categoryByLayerId: nextCats }; + }); + return; + } + + set({ selectedLayerIds: [...prev, id] }); + }, + + // - enforces max 2 layers + // - removes category filters for layers not selected + setSelectedLayerIds: (ids = []) => { + const safe = Array.isArray(ids) ? ids.slice(-2) : []; + set((state) => { + const nextCats = { ...state.categoryByLayerId }; + for (const key of Object.keys(nextCats)) { + if (!safe.includes(key)) delete nextCats[key]; + } + return { selectedLayerIds: safe, categoryByLayerId: nextCats }; + }); + }, + + setCategoryByLayerId: (map = {}) => { + set({ categoryByLayerId: map && typeof map === "object" ? map : {} }); + }, + + // Category filter per layer + categoryByLayerId: {}, + + setCategoryForLayer: (layerId, value) => + set((state) => ({ + categoryByLayerId: { + ...state.categoryByLayerId, + [layerId]: value || null, + }, + })), +})); diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 000000000..e3c4c4a99 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,4 @@ +[build] + base = "frontend" + command = "npm run build" + publish = "dist"