From 621bee1d994eca8cb7a0a1adf3d136fb38a43081 Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 16:52:32 -0400 Subject: [PATCH 01/56] Define Collection Archive product --- .gitignore | 6 ++++++ PRODUCT.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 .gitignore create mode 100644 PRODUCT.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06944d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +data/*.db +.DS_Store diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..3344d76 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,40 @@ +# Collection Archive + +## Purpose + +A public-facing archive that helps people discover which individual designers created collections for different fashion labels throughout their careers. + +## Version 1 users + +All users for now. + +## Home page + +For a small archive, the home page can initially show every designer. +Larger archive should show only recent or featured designers with a seperate +"View All" page. + +## Designer page + +Full Name, Country/Nationality, Birth Year, Website. Background/Bio + +## Collection page + +Each collection page should show basic details abt each collection: +Lead Designer +Label/Fashion House +Season +Release Year +Status (archived, released, concept, in-production, etc) +Piece Count +Description + +## Version 1 features + +CRUD functionality for Designers & Collections by any/all users. + +## Future features + +What are we deliberately postponing? +Postponing Authentication (login) for authorized edits vs everyday users. +Also collection pages should include images (a 2nd one-to-many relationship) From c04cbac280b78f8bba8dc648b614b88043957a58 Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 17:01:44 -0400 Subject: [PATCH 02/56] Add version one user stories --- PRODUCT.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/PRODUCT.md b/PRODUCT.md index 3344d76..2630432 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -1,12 +1,17 @@ -# Collection Archive +# Collection Archive ## Purpose A public-facing archive that helps people discover which individual designers created collections for different fashion labels throughout their careers. -## Version 1 users +## Version 1 user stories -All users for now. +- As a visitor, I can view all archived designers so I can discover who created fashio$ +- As a visitor, I can open a designer’s profile so I can learn about their career. +- As a visitor, I can view the collections credited to a designer across different lab$ +- As a visitor, I can open a collection to see its label, season, year, status, piece $ +- As a user, I can add, edit, and delete designer records. +- As a user, I can add, edit, and delete collection records. ## Home page From 10b02366cc7cc2e6dd6149eb63f87b2f0968f4fb Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 17:12:54 -0400 Subject: [PATCH 03/56] Define designer and collection data model --- DATA_MODEL.md | 38 ++++++++++++++++++++++++++++++++++++++ PRODUCT.md | 6 +++--- 2 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 DATA_MODEL.md diff --git a/DATA_MODEL.md b/DATA_MODEL.md new file mode 100644 index 0000000..6e43c1f --- /dev/null +++ b/DATA_MODEL.md @@ -0,0 +1,38 @@ +# Collection Archive Data Model + +## Designer + +Represents an individual fashion designer. + +| Field | Meaning | Required? | +|---|---|---| +| id | Internal unique identifier | Yes | +| full_name | Designer’s full name | Yes | +| nationality | Designer’s nationality | No | +| birth_year | Year the designer was born | No | +| website | Designer’s official website | No | +| biography | Background and career information | No | + +## Collection + +Represents a collection credited to one lead designer. + +| Field | Meaning | Required? | +|---|---|---| +| id | Internal unique identifier | Yes | +| designer_id | Identifies the lead designer | Yes | +| label | Label or fashion house that released it | Yes | +| name | Collection’s given name, if it has one | No | +| season | Fashion season, such as Spring/Summer | Yes | +| release_year | Year it was released or planned | Yes | +| status | Concept, in production, released, or archived | Yes | +| piece_count | Number of looks or pieces | No | +| description | Collection notes and context | No | + +A collection name is optional because many fashion collections are unnamed or eponymous and are instead identified by label, season, and year. + +## Relationship rules + +- One designer may have zero or many collections. +- Every collection must reference one existing designer. +- Deleting a designer deletes their collection records. diff --git a/PRODUCT.md b/PRODUCT.md index 2630432..578d6e5 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -6,9 +6,9 @@ A public-facing archive that helps people discover which individual designers cr ## Version 1 user stories -- As a visitor, I can view all archived designers so I can discover who created fashio$ -- As a visitor, I can open a designer’s profile so I can learn about their career. -- As a visitor, I can view the collections credited to a designer across different lab$ +- As a visitor, I can view all archived designers so I can discover who created fashion collections. +- As a visitor, I can view the collections credited to a designer across different labels and seasons. +- As a visitor, I can open a collection to see its label, season, year, status, piece count, and description. - As a visitor, I can open a collection to see its label, season, year, status, piece $ - As a user, I can add, edit, and delete designer records. - As a user, I can add, edit, and delete collection records. From ffc089786a7ffb00b7f0d992bf4ec17365b04f2d Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 17:32:42 -0400 Subject: [PATCH 04/56] Create designers table --- sql/schema.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 sql/schema.sql diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..82e33da --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,12 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE designers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + full_name TEXT NOT NULL UNIQUE + CHECK (length(trim(full_name)) > 0), + nationality TEXT, + birth_year INTEGER + CHECK (birth_year BETWEEN 1800 AND 2100), + website TEXT, + biography TEXT +); From 99f4249bcdff5bb660843b8197f50d1924ec6f85 Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 18:07:05 -0400 Subject: [PATCH 05/56] Add collections table and designer relationship --- sql/schema.sql | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/sql/schema.sql b/sql/schema.sql index 82e33da..5ab924a 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -1,3 +1,4 @@ + PRAGMA foreign_keys = ON; CREATE TABLE designers ( @@ -10,3 +11,52 @@ CREATE TABLE designers ( website TEXT, biography TEXT ); + + +CREATE TABLE collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + designer_id INTEGER NOT NULL, + + label TEXT NOT NULL + CHECK (length(trim(label)) > 0), + + name TEXT + CHECK (name IS NULL OR length(trim(name)) > 0), + + season TEXT NOT NULL + CHECK (length(trim(season)) > 0), + + release_year INTEGER NOT NULL + CHECK (release_year BETWEEN 1900 AND 2100), + + status TEXT NOT NULL + CHECK ( + status IN ( + 'concept', + 'in-production', + 'released', + 'archived' + ) + ), + + piece_count INTEGER + CHECK (piece_count IS NULL OR piece_count >= 0), + + description TEXT, + + FOREIGN KEY (designer_id) + REFERENCES designers(id) + ON UPDATE CASCADE + ON DELETE CASCADE, + + UNIQUE ( + designer_id, + label, + season, + release_year + ) +); + +CREATE INDEX idx_collections_designer_id + ON collections(designer_id); \ No newline at end of file From 32b28cf931c08d2ba6671c55ef801ff92cd6cee4 Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 18:12:25 -0400 Subject: [PATCH 06/56] Add sample archive data --- sql/seed.sql | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 sql/seed.sql diff --git a/sql/seed.sql b/sql/seed.sql new file mode 100644 index 0000000..1602fc8 --- /dev/null +++ b/sql/seed.sql @@ -0,0 +1,51 @@ +PRAGMA foreign_keys = ON; + +INSERT INTO designers ( + id, + full_name, + nationality, + birth_year, + website, + biography +) +VALUES + ( + 1, + 'Sarah Burton', + 'British', + NULL, + NULL, + 'Fashion designer whose career includes work for Alexander McQueen and Givenchy.' + ), + ( + 2, + 'Shayne Oliver', + 'American', + NULL, + NULL, + 'Fashion designer associated with multiple labels and creative projects.' + ); + + +INSERT INTO collections ( + id, + designer_id, + label, + name, + season, + release_year, + status, + piece_count, + description +) +VALUES ( + 1, + 1, + 'Givenchy', + NULL, + 'Fall/Winter', + 2025, + 'released', + 52, + 'The collection focused on cut, proportion, and tailoring.' +); From 521c2565e176a6c9130a9756fa853fff5e733123 Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 18:17:42 -0400 Subject: [PATCH 07/56] Add database initialization script --- scripts/init_db.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 scripts/init_db.py diff --git a/scripts/init_db.py b/scripts/init_db.py new file mode 100644 index 0000000..8541e2d --- /dev/null +++ b/scripts/init_db.py @@ -0,0 +1,27 @@ +import sqlite3 +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DATABASE_PATH = PROJECT_ROOT / "data" / "archive.db" +SCHEMA_PATH = PROJECT_ROOT / "sql" / "schema.sql" +SEED_PATH = PROJECT_ROOT / "sql" / "seed.sql" + + +DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True) + +if DATABASE_PATH.exists(): + DATABASE_PATH.unlink() + +connection = sqlite3.connect(DATABASE_PATH) +connection.execute("PRAGMA foreign_keys = ON") + +schema_sql = SCHEMA_PATH.read_text() +connection.executescript(schema_sql) + +seed_sql = SEED_PATH.read_text() +connection.executescript(seed_sql) + +connection.close() + +print(f"Database initialized at {DATABASE_PATH}") From 5d7c3d7dd8ee83868bde48e95f4b428c1f8b18f3 Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 18:37:07 -0400 Subject: [PATCH 08/56] Read designers from SQLite with Python --- app/list_designers.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 app/list_designers.py diff --git a/app/list_designers.py b/app/list_designers.py new file mode 100644 index 0000000..40c562b --- /dev/null +++ b/app/list_designers.py @@ -0,0 +1,28 @@ +import sqlite3 +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DATABASE_PATH = PROJECT_ROOT / "data" / "archive.db" + +connection = sqlite3.connect(DATABASE_PATH) +connection.row_factory = sqlite3.Row +connection.execute("PRAGMA foreign_keys = ON") + +rows = connection.execute( + """ + SELECT + id, + full_name, + nationality, + birth_year + FROM designers + ORDER BY full_name + """ +).fetchall() + +for row in rows: + designer = dict(row) + print(f"{designer['full_name']} — {designer['nationality']}") + +connection.close() From 2657af8919b5ef6bf4e8f9b3c88db04175a0f348 Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 18:38:47 -0400 Subject: [PATCH 09/56] Extract reusable database connection --- app/database.py | 13 +++++++++++++ app/list_designers.py | 10 ++-------- 2 files changed, 15 insertions(+), 8 deletions(-) create mode 100644 app/database.py diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..6d4c784 --- /dev/null +++ b/app/database.py @@ -0,0 +1,13 @@ +import sqlite3 +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DATABASE_PATH = PROJECT_ROOT / "data" / "archive.db" + + +def connect() -> sqlite3.Connection: + connection = sqlite3.connect(DATABASE_PATH) + connection.row_factory = sqlite3.Row + connection.execute("PRAGMA foreign_keys = ON") + return connection diff --git a/app/list_designers.py b/app/list_designers.py index 40c562b..0d08684 100644 --- a/app/list_designers.py +++ b/app/list_designers.py @@ -1,13 +1,7 @@ -import sqlite3 -from pathlib import Path +from database import connect -PROJECT_ROOT = Path(__file__).resolve().parent.parent -DATABASE_PATH = PROJECT_ROOT / "data" / "archive.db" - -connection = sqlite3.connect(DATABASE_PATH) -connection.row_factory = sqlite3.Row -connection.execute("PRAGMA foreign_keys = ON") +connection = connect() rows = connection.execute( """ From 8e443ca5b2b2f81f5a08eac666972df40a447ca7 Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 18:43:53 -0400 Subject: [PATCH 10/56] Create minimal FastAPI application --- app/main.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 app/main.py diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..796f2d0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,9 @@ +from fastapi import FastAPI + + +app = FastAPI(title="Collection Archive") + + +@app.get("/health") +def health(): + return {"status": "ok"} From 256754a479043a4bfad6810b1c17dadf786ea830 Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 18:46:27 -0400 Subject: [PATCH 11/56] Add designer list endpoint --- app/main.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/main.py b/app/main.py index 796f2d0..7e33de9 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,5 @@ from fastapi import FastAPI +from app.database import connect app = FastAPI(title="Collection Archive") @@ -7,3 +8,28 @@ @app.get("/health") def health(): return {"status": "ok"} + +@app.get("/designers") +def list_designers(): + connection = connect() + + try: + rows = connection.execute( + """ + SELECT + id, + full_name, + nationality, + birth_year, + website, + biography + FROM designers + ORDER BY full_name + """ + ).fetchall() + + return [dict(row) for row in rows] + finally: + connection.close() + + return designers From 7b12eae768737d0ee6d513ca71c09be2c80c717e Mon Sep 17 00:00:00 2001 From: hakeem Date: Thu, 13 Aug 2026 18:51:15 -0400 Subject: [PATCH 12/56] Add designer list endpoint --- app/main.py | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 7e33de9..8941f92 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from app.database import connect @@ -33,3 +33,33 @@ def list_designers(): connection.close() return designers + +@app.get("/designers/{designer_id}") +def get_designer(designer_id: int): + connection = connect() + + try: + row = connection.execute( + """ + SELECT + id, + full_name, + nationality, + birth_year, + website, + biography + FROM designers + WHERE id = ? + """, + (designer_id,), + ).fetchone() + + if row is None: + raise HTTPException( + status_code=404, + detail="Designer not found", + ) + + return dict(row) + finally: + connection.close() From bb042e37d2e1833442ae9faf9844285620638df1 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 08:51:51 -0400 Subject: [PATCH 13/56] List collections for a designer --- app/main.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/app/main.py b/app/main.py index 8941f92..de0d3af 100644 --- a/app/main.py +++ b/app/main.py @@ -63,3 +63,46 @@ def get_designer(designer_id: int): return dict(row) finally: connection.close() + +@app.get("/designers/{designer_id}/collections") +def list_designer_collections(designer_id: int): + connection = connect() + + try: + designer = connection.execute( + """ + SELECT id + FROM designers + WHERE id = ? + """, + (designer_id,), + ).fetchone() + + if designer is None: + raise HTTPException( + status_code=404, + detail="Designer not found", + ) + + rows = connection.execute( + """ + SELECT + id, + designer_id, + label, + name, + season, + release_year, + status, + piece_count, + description + FROM collections + WHERE designer_id = ? + ORDER BY release_year DESC, season + """, + (designer_id,), + ).fetchall() + + return [dict(row) for row in rows] + finally: + connection.close() From 8f6b86efd791056cfb35d5f87e8555da086c8890 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 08:53:30 -0400 Subject: [PATCH 14/56] Add collection detail endpoint --- app/main.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/app/main.py b/app/main.py index de0d3af..0621ed3 100644 --- a/app/main.py +++ b/app/main.py @@ -106,3 +106,39 @@ def list_designer_collections(designer_id: int): return [dict(row) for row in rows] finally: connection.close() + +@app.get("/collections/{collection_id}") +def get_collection(collection_id: int): + connection = connect() + + try: + row = connection.execute( + """ + SELECT + collections.id, + collections.designer_id, + designers.full_name AS lead_designer, + collections.label, + collections.name, + collections.season, + collections.release_year, + collections.status, + collections.piece_count, + collections.description + FROM collections + JOIN designers + ON designers.id = collections.designer_id + WHERE collections.id = ? + """, + (collection_id,), + ).fetchone() + + if row is None: + raise HTTPException( + status_code=404, + detail="Collection not found", + ) + + return dict(row) + finally: + connection.close() \ No newline at end of file From 0279f3732ba76e39ba17256d8eef0e2a94af1c75 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 09:02:08 -0400 Subject: [PATCH 15/56] Create designers with validated API input --- app/main.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++++- app/schemas.py | 19 ++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 app/schemas.py diff --git a/app/main.py b/app/main.py index 0621ed3..3c65d0f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,7 @@ -from fastapi import FastAPI, HTTPException +import sqlite3 +from fastapi import FastAPI, HTTPException, status from app.database import connect +from app.schemas import DesignerCreate app = FastAPI(title="Collection Archive") @@ -140,5 +142,60 @@ def get_collection(collection_id: int): ) return dict(row) + finally: + connection.close() + +@app.post("/designers", status_code=status.HTTP_201_CREATED) +def create_designer(payload: DesignerCreate): + connection = connect() + + try: + cursor = connection.execute( + """ + INSERT INTO designers ( + full_name, + nationality, + birth_year, + website, + biography + ) + VALUES (?, ?, ?, ?, ?) + """, + ( + payload.full_name, + payload.nationality, + payload.birth_year, + payload.website, + payload.biography, + ), + ) + + connection.commit() + + row = connection.execute( + """ + SELECT * + FROM designers + WHERE id = ? + """, + (cursor.lastrowid,), + ).fetchone() + + return dict(row) + + except sqlite3.IntegrityError as error: + connection.rollback() + + if "UNIQUE constraint failed: designers.full_name" in str(error): + raise HTTPException( + status_code=409, + detail="A designer with this name already exists", + ) from error + + raise HTTPException( + status_code=400, + detail="Designer violates a database constraint", + ) from error + finally: connection.close() \ No newline at end of file diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..5424892 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, Field, field_validator + + +class DesignerCreate(BaseModel): + full_name: str = Field(min_length=1, max_length=120) + nationality: str | None = None + birth_year: int | None = Field(default=None, ge=1800, le=2100) + website: str | None = None + biography: str | None = None + + @field_validator("full_name") + @classmethod + def full_name_must_not_be_blank(cls, value: str) -> str: + cleaned_value = value.strip() + + if not cleaned_value: + raise ValueError("Full name must not be blank") + + return cleaned_value From e8680a895e7be4d368c95d98f122128eb2192c9c Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 09:04:28 -0400 Subject: [PATCH 16/56] Add designer update endpoint --- app/main.py | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/app/main.py b/app/main.py index 3c65d0f..3050d83 100644 --- a/app/main.py +++ b/app/main.py @@ -197,5 +197,76 @@ def create_designer(payload: DesignerCreate): detail="Designer violates a database constraint", ) from error + finally: + connection.close() + +@app.put("/designers/{designer_id}") +def update_designer(designer_id: int, payload: DesignerCreate): + connection = connect() + + try: + existing_designer = connection.execute( + """ + SELECT id + FROM designers + WHERE id = ? + """, + (designer_id,), + ).fetchone() + + if existing_designer is None: + raise HTTPException( + status_code=404, + detail="Designer not found", + ) + + connection.execute( + """ + UPDATE designers + SET + full_name = ?, + nationality = ?, + birth_year = ?, + website = ?, + biography = ? + WHERE id = ? + """, + ( + payload.full_name, + payload.nationality, + payload.birth_year, + payload.website, + payload.biography, + designer_id, + ), + ) + + connection.commit() + + updated_designer = connection.execute( + """ + SELECT * + FROM designers + WHERE id = ? + """, + (designer_id,), + ).fetchone() + + return dict(updated_designer) + + except sqlite3.IntegrityError as error: + connection.rollback() + + if "UNIQUE constraint failed: designers.full_name" in str(error): + raise HTTPException( + status_code=409, + detail="A designer with this name already exists", + ) from error + + raise HTTPException( + status_code=400, + detail="Designer violates a database constraint", + ) from error + finally: connection.close() \ No newline at end of file From d63695fe6523e1f1a120d10d5eea96ee0dd50c8c Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 09:06:15 -0400 Subject: [PATCH 17/56] Add designer deletion endpoint --- app/main.py | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 3050d83..cc2fde5 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,5 @@ import sqlite3 -from fastapi import FastAPI, HTTPException, status +from fastapi import FastAPI, HTTPException, Response, status from app.database import connect from app.schemas import DesignerCreate @@ -268,5 +268,44 @@ def update_designer(designer_id: int, payload: DesignerCreate): detail="Designer violates a database constraint", ) from error + finally: + connection.close() + +@app.delete( + "/designers/{designer_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_designer(designer_id: int): + connection = connect() + + try: + existing_designer = connection.execute( + """ + SELECT id + FROM designers + WHERE id = ? + """, + (designer_id,), + ).fetchone() + + if existing_designer is None: + raise HTTPException( + status_code=404, + detail="Designer not found", + ) + + connection.execute( + """ + DELETE FROM designers + WHERE id = ? + """, + (designer_id,), + ) + + connection.commit() + + return Response( + status_code=status.HTTP_204_NO_CONTENT + ) finally: connection.close() \ No newline at end of file From ad53eb389b999a9d0c79a77a576424cb2539cd9e Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 09:09:53 -0400 Subject: [PATCH 18/56] Define validated collection input --- app/schemas.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/app/schemas.py b/app/schemas.py index 5424892..fc056e2 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,5 +1,12 @@ from pydantic import BaseModel, Field, field_validator +from typing import Literal +CollectionStatus = Literal[ + "concept", + "in-production", + "released", + "archived", +] class DesignerCreate(BaseModel): full_name: str = Field(min_length=1, max_length=120) @@ -17,3 +24,41 @@ def full_name_must_not_be_blank(cls, value: str) -> str: raise ValueError("Full name must not be blank") return cleaned_value + +class CollectionCreate(BaseModel): + designer_id: int = Field(ge=1) + label: str = Field(min_length=1, max_length=120) + name: str | None = Field(default=None, max_length=120) + season: str = Field(min_length=1, max_length=40) + release_year: int = Field(ge=1900, le=2100) + status: CollectionStatus + piece_count: int | None = Field(default=None, ge=0) + description: str | None = None + + @field_validator("label", "season") + @classmethod + def required_text_must_not_be_blank(cls, value: str) -> str: + cleaned_value = value.strip() + + if not cleaned_value: + raise ValueError("Value must not be blank") + + return cleaned_value + + @field_validator("name") + @classmethod + def optional_name_must_not_be_blank( + cls, + value: str | None, + ) -> str | None: + if value is None: + return None + + cleaned_value = value.strip() + + if not cleaned_value: + raise ValueError( + "Name must be meaningful when provided" + ) + + return cleaned_value \ No newline at end of file From 7a1ba4702101f9fac65dfe02d29df1214595f93f Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 09:12:04 -0400 Subject: [PATCH 19/56] Add collection creation endpoint --- app/main.py | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index cc2fde5..fae729f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,7 @@ import sqlite3 from fastapi import FastAPI, HTTPException, Response, status from app.database import connect -from app.schemas import DesignerCreate +from app.schemas import CollectionCreate, DesignerCreate app = FastAPI(title="Collection Archive") @@ -307,5 +307,84 @@ def delete_designer(designer_id: int): return Response( status_code=status.HTTP_204_NO_CONTENT ) + finally: + connection.close() + +@app.post( + "/collections", + status_code=status.HTTP_201_CREATED, +) +def create_collection(payload: CollectionCreate): + connection = connect() + + try: + designer = connection.execute( + """ + SELECT id + FROM designers + WHERE id = ? + """, + (payload.designer_id,), + ).fetchone() + + if designer is None: + raise HTTPException( + status_code=404, + detail="Designer not found", + ) + + cursor = connection.execute( + """ + INSERT INTO collections ( + designer_id, + label, + name, + season, + release_year, + status, + piece_count, + description + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + payload.designer_id, + payload.label, + payload.name, + payload.season, + payload.release_year, + payload.status, + payload.piece_count, + payload.description, + ), + ) + + connection.commit() + + row = connection.execute( + """ + SELECT * + FROM collections + WHERE id = ? + """, + (cursor.lastrowid,), + ).fetchone() + + return dict(row) + + except sqlite3.IntegrityError as error: + connection.rollback() + + if "UNIQUE constraint failed" in str(error): + raise HTTPException( + status_code=409, + detail="This collection already exists", + ) from error + + raise HTTPException( + status_code=400, + detail="Collection violates a database constraint", + ) from error + finally: connection.close() \ No newline at end of file From f38bac26a826bd6025dc09ca567398e976e5aaa3 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 09:14:22 -0400 Subject: [PATCH 20/56] Add collection update and delete endpoints --- app/main.py | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/app/main.py b/app/main.py index fae729f..40135ed 100644 --- a/app/main.py +++ b/app/main.py @@ -386,5 +386,139 @@ def create_collection(payload: CollectionCreate): detail="Collection violates a database constraint", ) from error + finally: + connection.close() + +@app.put("/collections/{collection_id}") +def update_collection( + collection_id: int, + payload: CollectionCreate, +): + connection = connect() + + try: + existing_collection = connection.execute( + """ + SELECT id + FROM collections + WHERE id = ? + """, + (collection_id,), + ).fetchone() + + if existing_collection is None: + raise HTTPException( + status_code=404, + detail="Collection not found", + ) + + designer = connection.execute( + """ + SELECT id + FROM designers + WHERE id = ? + """, + (payload.designer_id,), + ).fetchone() + + if designer is None: + raise HTTPException( + status_code=404, + detail="Designer not found", + ) + + connection.execute( + """ + UPDATE collections + SET + designer_id = ?, + label = ?, + name = ?, + season = ?, + release_year = ?, + status = ?, + piece_count = ?, + description = ? + WHERE id = ? + """, + ( + payload.designer_id, + payload.label, + payload.name, + payload.season, + payload.release_year, + payload.status, + payload.piece_count, + payload.description, + collection_id, + ), + ) + + connection.commit() + + updated_collection = connection.execute( + """ + SELECT * + FROM collections + WHERE id = ? + """, + (collection_id,), + ).fetchone() + + return dict(updated_collection) + + except sqlite3.IntegrityError as error: + connection.rollback() + + if "UNIQUE constraint failed" in str(error): + raise HTTPException( + status_code=409, + detail="This collection already exists", + ) from error + + raise HTTPException( + status_code=400, + detail="Collection violates a database constraint", + ) from error + + finally: + connection.close() + +@app.delete( + "/collections/{collection_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_collection(collection_id: int): + connection = connect() + + try: + existing_collection = connection.execute( + """ + SELECT id + FROM collections + WHERE id = ? + """, + (collection_id,), + ).fetchone() + + if existing_collection is None: + raise HTTPException( + status_code=404, + detail="Collection not found", + ) + + connection.execute( + """ + DELETE FROM collections + WHERE id = ? + """, + (collection_id,), + ) + + connection.commit() + + return Response( + status_code=status.HTTP_204_NO_CONTENT + ) finally: connection.close() \ No newline at end of file From 1c7828c9fb265a82335ce6cb590a71a99f587c76 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 09:25:34 -0400 Subject: [PATCH 21/56] Add complete collection list endpoint --- app/main.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/main.py b/app/main.py index 40135ed..7fb4014 100644 --- a/app/main.py +++ b/app/main.py @@ -520,5 +520,37 @@ def delete_collection(collection_id: int): return Response( status_code=status.HTTP_204_NO_CONTENT ) + finally: + connection.close() + + +@app.get("/collections") +def list_collections(): + connection = connect() + + try: + rows = connection.execute( + """ + SELECT + collections.id, + collections.designer_id, + designers.full_name AS lead_designer, + collections.label, + collections.name, + collections.season, + collections.release_year, + collections.status, + collections.piece_count, + collections.description + FROM collections + JOIN designers + ON designers.id = collections.designer_id + ORDER BY + collections.release_year DESC, + collections.label + """ + ).fetchall() + + return [dict(row) for row in rows] finally: connection.close() \ No newline at end of file From 213d9e7b6f2465f0f2853b8625853e11a43ebc2f Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 09:29:32 -0400 Subject: [PATCH 22/56] Document Python runtime dependencies --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..59c8921 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.141.1 +uvicorn[standard]==0.52.1 From 58919fcb0f4a50400b88f75dd771392ce07afb29 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 11:04:22 -0400 Subject: [PATCH 23/56] Remove unreachable designer list code --- app/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/main.py b/app/main.py index 7fb4014..713e9d6 100644 --- a/app/main.py +++ b/app/main.py @@ -34,7 +34,6 @@ def list_designers(): finally: connection.close() - return designers @app.get("/designers/{designer_id}") def get_designer(designer_id: int): From ab12474b2d56eaa20cd19ffa46f25be21efbc849 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 11:08:01 -0400 Subject: [PATCH 24/56] Add initial API tests --- requirements.txt | 2 ++ tests/test_api.py | 79 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tests/test_api.py diff --git a/requirements.txt b/requirements.txt index 59c8921..fec1871 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ fastapi==0.141.1 uvicorn[standard]==0.52.1 +pytest==8.4.2 +httpx==0.28.1 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..9974241 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,79 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from app import database +from app.main import app + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + + +@pytest.fixture +def client(tmp_path, monkeypatch): + test_database_path = tmp_path / "test.db" + + monkeypatch.setattr( + database, + "DATABASE_PATH", + test_database_path, + ) + + connection = database.connect() + + try: + schema_sql = ( + PROJECT_ROOT / "sql" / "schema.sql" + ).read_text() + + seed_sql = ( + PROJECT_ROOT / "sql" / "seed.sql" + ).read_text() + + connection.executescript(schema_sql) + connection.executescript(seed_sql) + finally: + connection.close() + + with TestClient(app) as test_client: + yield test_client + + +def test_list_designers(client): + response = client.get("/designers") + + assert response.status_code == 200 + + designers = response.json() + + assert len(designers) == 2 + assert designers[0]["full_name"] == "Sarah Burton" + assert designers[1]["full_name"] == "Shayne Oliver" + +def test_get_designer(client): + response = client.get("/designers/1") + + assert response.status_code == 200 + assert response.json()["full_name"] == "Sarah Burton" + + +def test_missing_designer_returns_404(client): + response = client.get("/designers/999") + + assert response.status_code == 404 + assert response.json() == { + "detail": "Designer not found" + } + + +def test_list_collections_for_designer(client): + response = client.get("/designers/1/collections") + + assert response.status_code == 200 + + collections = response.json() + + assert len(collections) == 1 + assert collections[0]["label"] == "Givenchy" + assert collections[0]["designer_id"] == 1 From 8dcc596bab14aeda545dbd496217fc42ae3c21db Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 11:10:09 -0400 Subject: [PATCH 25/56] Test cascading designer deletion --- tests/test_api.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_api.py b/tests/test_api.py index 9974241..0e6515e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -77,3 +77,47 @@ def test_list_collections_for_designer(client): assert len(collections) == 1 assert collections[0]["label"] == "Givenchy" assert collections[0]["designer_id"] == 1 + +def test_deleting_designer_cascades_to_collections(client): + designer_response = client.post( + "/designers", + json={ + "full_name": "Temporary Cascade Designer", + "nationality": None, + "birth_year": None, + "website": None, + "biography": None, + }, + ) + + assert designer_response.status_code == 201 + designer_id = designer_response.json()["id"] + + collection_response = client.post( + "/collections", + json={ + "designer_id": designer_id, + "label": "Temporary Label", + "name": None, + "season": "Resort", + "release_year": 2026, + "status": "concept", + "piece_count": None, + "description": "Temporary cascade test.", + }, + ) + + assert collection_response.status_code == 201 + collection_id = collection_response.json()["id"] + + delete_response = client.delete( + f"/designers/{designer_id}" + ) + + assert delete_response.status_code == 204 + + missing_collection_response = client.get( + f"/collections/{collection_id}" + ) + + assert missing_collection_response.status_code == 404 From c66df286c3f5472000416780143934defaee281c Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 11:46:15 -0400 Subject: [PATCH 26/56] Add Collection Archive home page --- app/main.py | 9 ++++++++- web/index.html | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 web/index.html diff --git a/app/main.py b/app/main.py index 713e9d6..76376ed 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ from fastapi import FastAPI, HTTPException, Response, status from app.database import connect from app.schemas import CollectionCreate, DesignerCreate +from fastapi.staticfiles import StaticFiles app = FastAPI(title="Collection Archive") @@ -552,4 +553,10 @@ def list_collections(): return [dict(row) for row in rows] finally: - connection.close() \ No newline at end of file + connection.close() + +app.mount( + "/", + StaticFiles(directory="web", html=True), + name="web", +) \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..eddf60a --- /dev/null +++ b/web/index.html @@ -0,0 +1,25 @@ + + + + + + Collection Archive + + +
+

Collection Archive

+

+ Discover the designers behind collections from + different fashion labels and seasons. +

+
+ +
+

Designers

+

The designer archive will appear here.

+
+ + From 742c6ae183173298137310897781505fdd96ec9e Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 11:53:32 -0400 Subject: [PATCH 27/56] Load designers on archive home page --- web/app.js | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ web/index.html | 5 ++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 web/app.js diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..f89ba5f --- /dev/null +++ b/web/app.js @@ -0,0 +1,48 @@ +async function loadDesigners() { + const status = document.querySelector("#status"); + const designerList = document.querySelector("#designer-list"); + + try { + const response = await fetch("/designers"); + + if (!response.ok) { + throw new Error("Could not load designers"); + } + + const designers = await response.json(); + + status.textContent = ""; + + for (const designer of designers) { + const listItem = document.createElement("li"); + const heading = document.createElement("h3"); + const details = document.createElement("p"); + + heading.textContent = designer.full_name; + + const detailParts = []; + + if (designer.nationality) { + detailParts.push(designer.nationality); + } + + if (designer.birth_year) { + detailParts.push(`Born ${designer.birth_year}`); + } + + details.textContent = + detailParts.join(" · ") || + "Additional details unavailable"; + + listItem.append(heading, details); + designerList.append(listItem); + } + } catch (error) { + status.textContent = + "The designer archive could not be loaded."; + console.error(error); + } +} + + +loadDesigners(); diff --git a/web/index.html b/web/index.html index eddf60a..a07ba7d 100644 --- a/web/index.html +++ b/web/index.html @@ -19,7 +19,10 @@

Collection Archive

Designers

-

The designer archive will appear here.

+

Loading designers...

+
    + + From 9527fad1f1e421fca737b0d6a3d3e88016667c38 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 12:01:43 -0400 Subject: [PATCH 28/56] Add designer profile pages --- web/app.js | 5 +++- web/designer.html | 31 ++++++++++++++++++++ web/designer.js | 72 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 web/designer.html create mode 100644 web/designer.js diff --git a/web/app.js b/web/app.js index f89ba5f..1909486 100644 --- a/web/app.js +++ b/web/app.js @@ -16,9 +16,12 @@ async function loadDesigners() { for (const designer of designers) { const listItem = document.createElement("li"); const heading = document.createElement("h3"); + const link = document.createElement("a"); const details = document.createElement("p"); - heading.textContent = designer.full_name; + link.textContent = designer.full_name; + link.href = `/designer.html?id=${designer.id}`; + heading.append(link); const detailParts = []; diff --git a/web/designer.html b/web/designer.html new file mode 100644 index 0000000..1205621 --- /dev/null +++ b/web/designer.html @@ -0,0 +1,31 @@ + + + + + + Designer | Collection Archive + + + + +
    +

    Loading designer...

    + + +
    + + + + diff --git a/web/designer.js b/web/designer.js new file mode 100644 index 0000000..5688c4c --- /dev/null +++ b/web/designer.js @@ -0,0 +1,72 @@ +async function loadDesigner() { + const parameters = new URLSearchParams(window.location.search); + const designerId = parameters.get("id"); + + const status = document.querySelector("#status"); + const profile = document.querySelector("#profile"); + + if (!designerId) { + status.textContent = "No designer was selected."; + return; + } + + try { + const designerResponse = await fetch( + `/designers/${designerId}` + ); + + if (!designerResponse.ok) { + throw new Error("Designer not found"); + } + + const collectionsResponse = await fetch( + `/designers/${designerId}/collections` + ); + + if (!collectionsResponse.ok) { + throw new Error("Collections could not be loaded"); + } + + const designer = await designerResponse.json(); + const collections = await collectionsResponse.json(); + + document.querySelector("#designer-name").textContent = + designer.full_name; + + const details = [ + designer.nationality, + designer.birth_year + ? `Born ${designer.birth_year}` + : null, + ].filter(Boolean); + + document.querySelector("#designer-details").textContent = + details.join(" · "); + + document.querySelector("#designer-biography").textContent = + designer.biography || "No biography is available."; + + const collectionList = + document.querySelector("#collection-list"); + + for (const collection of collections) { + const item = document.createElement("li"); + + item.textContent = + `${collection.label} — ` + + `${collection.season} ${collection.release_year}`; + + collectionList.append(item); + } + + status.textContent = ""; + profile.hidden = false; + } catch (error) { + status.textContent = + "This designer profile could not be loaded."; + console.error(error); + } +} + + +loadDesigner(); From 76c68f6823318a28c65d8720b63932bbe8301432 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 12:04:48 -0400 Subject: [PATCH 29/56] Add collection detail page --- web/collection.html | 31 ++++++++++++++++++++ web/collection.js | 69 +++++++++++++++++++++++++++++++++++++++++++++ web/designer.js | 8 +++++- 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 web/collection.html create mode 100644 web/collection.js diff --git a/web/collection.html b/web/collection.html new file mode 100644 index 0000000..2f6138f --- /dev/null +++ b/web/collection.html @@ -0,0 +1,31 @@ + + + + + + Collection | Collection Archive + + + + +
    +

    Loading collection...

    + + +
    + + + + diff --git a/web/collection.js b/web/collection.js new file mode 100644 index 0000000..1db33e7 --- /dev/null +++ b/web/collection.js @@ -0,0 +1,69 @@ +async function loadCollection() { + const parameters = new URLSearchParams(window.location.search); + const collectionId = parameters.get("id"); + + const statusMessage = document.querySelector("#status"); + const collectionArticle = + document.querySelector("#collection"); + + if (!collectionId) { + statusMessage.textContent = + "No collection was selected."; + return; + } + + try { + const response = await fetch( + `/collections/${collectionId}` + ); + + if (!response.ok) { + throw new Error("Collection not found"); + } + + const collection = await response.json(); + + document.querySelector("#collection-title").textContent = + collection.name || collection.label; + + const designerLink = document.createElement("a"); + designerLink.textContent = collection.lead_designer; + designerLink.href = + `/designer.html?id=${collection.designer_id}`; + + const designerContainer = + document.querySelector("#collection-designer"); + + designerContainer.textContent = "Lead designer: "; + designerContainer.append(designerLink); + + document.querySelector("#collection-season").textContent = + `${collection.label} · ` + + `${collection.season} ${collection.release_year}`; + + document.querySelector("#collection-status").textContent = + `Status: ${collection.status}`; + + document.querySelector( + "#collection-piece-count" + ).textContent = collection.piece_count === null + ? "Piece count unavailable" + : `Piece count: ${collection.piece_count}`; + + document.querySelector( + "#collection-description" + ).textContent = + collection.description || + "No description is available."; + + statusMessage.textContent = ""; + collectionArticle.hidden = false; + } catch (error) { + statusMessage.textContent = + "This collection could not be loaded."; + console.error(error); + } +} + + +loadCollection(); diff --git a/web/designer.js b/web/designer.js index 5688c4c..6be1283 100644 --- a/web/designer.js +++ b/web/designer.js @@ -52,10 +52,16 @@ async function loadDesigner() { for (const collection of collections) { const item = document.createElement("li"); - item.textContent = + const link = document.createElement("a"); + + link.textContent = `${collection.label} — ` + `${collection.season} ${collection.release_year}`; + link.href = `/collection.html?id=${collection.id}`; + + item.append(link); + collectionList.append(item); } From 355ad2578e22b8540cf34fd87936feb553e79e63 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 13:07:17 -0400 Subject: [PATCH 30/56] Add shared archive styling --- web/collection.html | 1 + web/designer.html | 1 + web/index.html | 1 + web/styles.css | 50 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 web/styles.css diff --git a/web/collection.html b/web/collection.html index 2f6138f..2fdc126 100644 --- a/web/collection.html +++ b/web/collection.html @@ -6,6 +6,7 @@ name="viewport" content="width=device-width, initial-scale=1" > + Collection | Collection Archive diff --git a/web/designer.html b/web/designer.html index 1205621..a5b5fa2 100644 --- a/web/designer.html +++ b/web/designer.html @@ -6,6 +6,7 @@ name="viewport" content="width=device-width, initial-scale=1" > + Designer | Collection Archive diff --git a/web/index.html b/web/index.html index a07ba7d..ec0602b 100644 --- a/web/index.html +++ b/web/index.html @@ -6,6 +6,7 @@ name="viewport" content="width=device-width, initial-scale=1" > + Collection Archive diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..c854497 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,50 @@ +body { + max-width: 760px; + margin: 0 auto; + padding: 24px; + color: #222; + background: #f7f5f1; + font-family: Georgia, serif; + line-height: 1.6; +} + +header, +nav, +main { + margin-bottom: 32px; +} + +h1, +h2, +h3 { + line-height: 1.2; +} + +a { + color: #663c2f; +} + +a:hover { + color: #a35f49; +} + +ul { + padding: 0; + list-style: none; +} + +li { + margin-bottom: 16px; + padding: 16px; + background: white; + border: 1px solid #ddd7ce; +} + +#status { + color: #666; +} + +#designer-biography, +#collection-description { + max-width: 65ch; +} From 52700e5e461f35acc4a9003961189768e4acc3e1 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 13:11:53 -0400 Subject: [PATCH 31/56] Add designer creation form --- web/designer-form.html | 77 ++++++++++++++++++++++++++++++++++++++++++ web/designer-form.js | 43 +++++++++++++++++++++++ web/index.html | 3 ++ 3 files changed, 123 insertions(+) create mode 100644 web/designer-form.html create mode 100644 web/designer-form.js diff --git a/web/designer-form.html b/web/designer-form.html new file mode 100644 index 0000000..d5ca1ca --- /dev/null +++ b/web/designer-form.html @@ -0,0 +1,77 @@ + + + + + + + Add Designer | Collection Archive + + + + +
    +

    Add a Designer

    + +

    + +
    +

    +
    + +

    + +

    +
    + +

    + +

    +
    + +

    + +

    +
    + +

    + +

    +
    + +

    + + +
    +
    + + + + diff --git a/web/designer-form.js b/web/designer-form.js new file mode 100644 index 0000000..5fecec8 --- /dev/null +++ b/web/designer-form.js @@ -0,0 +1,43 @@ +const form = document.querySelector("#designer-form"); +const statusMessage = document.querySelector("#status"); + + +form.addEventListener("submit", async function (event) { + event.preventDefault(); + + const formData = new FormData(form); + const birthYear = formData.get("birth_year"); + + const payload = { + full_name: formData.get("full_name"), + nationality: formData.get("nationality") || null, + birth_year: birthYear ? Number(birthYear) : null, + website: formData.get("website") || null, + biography: formData.get("biography") || null, + }; + + statusMessage.textContent = "Saving designer..."; + + try { + const response = await fetch("/designers", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error( + result.detail || "Designer could not be saved" + ); + } + + window.location.href = + `/designer.html?id=${result.id}`; + } catch (error) { + statusMessage.textContent = error.message; + } +}); diff --git a/web/index.html b/web/index.html index ec0602b..90bc66e 100644 --- a/web/index.html +++ b/web/index.html @@ -20,6 +20,9 @@

    Collection Archive

    Designers

    +

    + Add a designer +

    Loading designers...

      From f84b74b5562b0eed111c926cd91f38383e2cc622 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 13:17:26 -0400 Subject: [PATCH 32/56] Add collection creation form --- web/collection-form.html | 88 ++++++++++++++++++++++++++++++++++++++++ web/collection-form.js | 55 +++++++++++++++++++++++++ web/designer.html | 5 +++ web/designer.js | 3 ++ 4 files changed, 151 insertions(+) create mode 100644 web/collection-form.html create mode 100644 web/collection-form.js diff --git a/web/collection-form.html b/web/collection-form.html new file mode 100644 index 0000000..8d29f40 --- /dev/null +++ b/web/collection-form.html @@ -0,0 +1,88 @@ + + + + + + + Add Collection | Collection Archive + + + + +
      +

      Add a Collection

      +

      + +
      +

      +
      + +

      + +

      +
      + +

      + +

      +
      + +

      + +

      +
      + +

      + +

      +
      + +

      + +

      +
      + +

      + +

      +
      + +

      + + +
      +
      + + + + diff --git a/web/collection-form.js b/web/collection-form.js new file mode 100644 index 0000000..aa86c6c --- /dev/null +++ b/web/collection-form.js @@ -0,0 +1,55 @@ +const parameters = new URLSearchParams(window.location.search); +const designerId = parameters.get("designer_id"); + +const form = document.querySelector("#collection-form"); +const statusMessage = document.querySelector("#status"); + + +if (!designerId) { + statusMessage.textContent = "No designer was selected."; + form.hidden = true; +} + + +form.addEventListener("submit", async function (event) { + event.preventDefault(); + + const formData = new FormData(form); + const pieceCount = formData.get("piece_count"); + + const payload = { + designer_id: Number(designerId), + label: formData.get("label"), + name: formData.get("name") || null, + season: formData.get("season"), + release_year: Number(formData.get("release_year")), + status: formData.get("status"), + piece_count: pieceCount ? Number(pieceCount) : null, + description: formData.get("description") || null, + }; + + statusMessage.textContent = "Saving collection..."; + + try { + const response = await fetch("/collections", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error( + result.detail || "Collection could not be saved" + ); + } + + window.location.href = + `/collection.html?id=${result.id}`; + } catch (error) { + statusMessage.textContent = error.message; + } +}); diff --git a/web/designer.html b/web/designer.html index a5b5fa2..2af1241 100644 --- a/web/designer.html +++ b/web/designer.html @@ -23,6 +23,11 @@

      Collections

      +

      + + Add a collection + +

        diff --git a/web/designer.js b/web/designer.js index 6be1283..591fb06 100644 --- a/web/designer.js +++ b/web/designer.js @@ -46,6 +46,9 @@ async function loadDesigner() { document.querySelector("#designer-biography").textContent = designer.biography || "No biography is available."; + document.querySelector("#add-collection-link").href = + `/collection-form.html?designer_id=${designerId}`; + const collectionList = document.querySelector("#collection-list"); From d7a2ce2decab584e8110336ee78d8b6246094de2 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 13:21:18 -0400 Subject: [PATCH 33/56] Add collection deletion control --- web/collection.html | 5 +++++ web/collection.js | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/web/collection.html b/web/collection.html index 2fdc126..dd79ff5 100644 --- a/web/collection.html +++ b/web/collection.html @@ -24,6 +24,11 @@

        +

        + +

        diff --git a/web/collection.js b/web/collection.js index 1db33e7..d4c0594 100644 --- a/web/collection.js +++ b/web/collection.js @@ -58,6 +58,38 @@ async function loadCollection() { statusMessage.textContent = ""; collectionArticle.hidden = false; + const deleteButton = + document.querySelector("#delete-collection"); + + deleteButton.addEventListener("click", async function () { + const confirmed = window.confirm( + "Delete this collection permanently?" + ); + + if (!confirmed) { + return; + } + + try { + const deleteResponse = await fetch( + `/collections/${collection.id}`, + { + method: "DELETE", + } + ); + + if (!deleteResponse.ok) { + throw new Error( + "Collection could not be deleted" + ); + } + + window.location.href = + `/designer.html?id=${collection.designer_id}`; + } catch (error) { + statusMessage.textContent = error.message; + } + }); } catch (error) { statusMessage.textContent = "This collection could not be loaded."; From 04901cb689565a899fa8bb333544402e3c88a9b8 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 13:36:43 -0400 Subject: [PATCH 34/56] Add collection editing interface --- web/collection-form.html | 6 ++-- web/collection-form.js | 73 +++++++++++++++++++++++++++++++++++++--- web/collection.html | 5 +++ web/collection.js | 2 ++ 4 files changed, 80 insertions(+), 6 deletions(-) diff --git a/web/collection-form.html b/web/collection-form.html index 8d29f40..fd84144 100644 --- a/web/collection-form.html +++ b/web/collection-form.html @@ -15,7 +15,7 @@
        -

        Add a Collection

        +

        Add a Collection

        @@ -79,7 +79,9 @@

        Add a Collection

        >

        - +
        diff --git a/web/collection-form.js b/web/collection-form.js index aa86c6c..8771bef 100644 --- a/web/collection-form.js +++ b/web/collection-form.js @@ -1,15 +1,73 @@ const parameters = new URLSearchParams(window.location.search); -const designerId = parameters.get("designer_id"); +let designerId = parameters.get("designer_id"); +const collectionId = parameters.get("collection_id"); +const isEditing = Boolean(collectionId); const form = document.querySelector("#collection-form"); const statusMessage = document.querySelector("#status"); -if (!designerId) { +if (!designerId && !isEditing) { statusMessage.textContent = "No designer was selected."; form.hidden = true; } +async function loadCollectionForEditing() { + if (!isEditing) { + return; + } + + statusMessage.textContent = "Loading collection..."; + + try { + const response = await fetch( + `/collections/${collectionId}` + ); + + if (!response.ok) { + throw new Error("Collection could not be loaded"); + } + + const collection = await response.json(); + + designerId = collection.designer_id; + + document.querySelector("#form-title").textContent = + "Edit Collection"; + + document.querySelector("#submit-button").textContent = + "Save changes"; + + document.querySelector("#label").value = + collection.label; + + document.querySelector("#name").value = + collection.name || ""; + + document.querySelector("#season").value = + collection.season; + + document.querySelector("#release-year").value = + collection.release_year; + + document.querySelector("#collection-status").value = + collection.status; + + document.querySelector("#piece-count").value = + collection.piece_count ?? ""; + + document.querySelector("#description").value = + collection.description || ""; + + statusMessage.textContent = ""; + } catch (error) { + statusMessage.textContent = error.message; + form.hidden = true; + } +} + + +loadCollectionForEditing(); form.addEventListener("submit", async function (event) { event.preventDefault(); @@ -31,8 +89,15 @@ form.addEventListener("submit", async function (event) { statusMessage.textContent = "Saving collection..."; try { - const response = await fetch("/collections", { - method: "POST", + const endpoint = isEditing + ? `/collections/${collectionId}` + : "/collections"; + + const method = isEditing + ? "PUT" + : "POST"; + const response = await fetch(endpoint, { + method: method, headers: { "Content-Type": "application/json", }, diff --git a/web/collection.html b/web/collection.html index dd79ff5..f2a29a0 100644 --- a/web/collection.html +++ b/web/collection.html @@ -24,6 +24,11 @@

        +

        + + Edit collection + +

        + diff --git a/web/designer-form.js b/web/designer-form.js index 5fecec8..159c931 100644 --- a/web/designer-form.js +++ b/web/designer-form.js @@ -1,26 +1,95 @@ +const parameters = new URLSearchParams(window.location.search); +const designerId = parameters.get("id"); +const isEditing = Boolean(designerId); + const form = document.querySelector("#designer-form"); const statusMessage = document.querySelector("#status"); +async function loadDesignerForEditing() { + if (!isEditing) { + return; + } + + statusMessage.textContent = "Loading designer..."; + + try { + const response = await fetch( + `/designers/${designerId}` + ); + + if (!response.ok) { + throw new Error("Designer could not be loaded"); + } + + const designer = await response.json(); + + document.querySelector("#form-title").textContent = + "Edit Designer"; + + document.querySelector("#submit-button").textContent = + "Save changes"; + + document.querySelector("#full-name").value = + designer.full_name; + + document.querySelector("#nationality").value = + designer.nationality || ""; + + document.querySelector("#birth-year").value = + designer.birth_year ?? ""; + + document.querySelector("#website").value = + designer.website || ""; + + document.querySelector("#biography").value = + designer.biography || ""; + + statusMessage.textContent = ""; + } catch (error) { + statusMessage.textContent = error.message; + form.hidden = true; + } +} + + form.addEventListener("submit", async function (event) { event.preventDefault(); const formData = new FormData(form); const birthYear = formData.get("birth_year"); + const websiteInput = + formData.get("website").trim(); + + const website = + websiteInput && + !websiteInput.startsWith("http://") && + !websiteInput.startsWith("https://") + ? `https://${websiteInput}` + : websiteInput || null; + const payload = { full_name: formData.get("full_name"), nationality: formData.get("nationality") || null, birth_year: birthYear ? Number(birthYear) : null, - website: formData.get("website") || null, + website: website, biography: formData.get("biography") || null, }; statusMessage.textContent = "Saving designer..."; try { - const response = await fetch("/designers", { - method: "POST", + const endpoint = isEditing + ? `/designers/${designerId}` + : "/designers"; + + const method = isEditing + ? "PUT" + : "POST"; + + const response = await fetch(endpoint, { + method: method, headers: { "Content-Type": "application/json", }, @@ -41,3 +110,6 @@ form.addEventListener("submit", async function (event) { statusMessage.textContent = error.message; } }); + + +loadDesignerForEditing(); \ No newline at end of file diff --git a/web/designer.html b/web/designer.html index 2af1241..5a4aa04 100644 --- a/web/designer.html +++ b/web/designer.html @@ -20,6 +20,7 @@

        diff --git a/web/designer.js b/web/designer.js index 591fb06..a218d6b 100644 --- a/web/designer.js +++ b/web/designer.js @@ -29,6 +29,8 @@ async function loadDesigner() { const designer = await designerResponse.json(); const collections = await collectionsResponse.json(); + document.querySelector("#edit-designer").href = + `/designer-form.html?id=${designer.id}`; document.querySelector("#designer-name").textContent = designer.full_name; @@ -40,6 +42,23 @@ async function loadDesigner() { : null, ].filter(Boolean); + const websiteContainer = + document.querySelector("#designer-website"); + + if (designer.website) { + const websiteLink = document.createElement("a"); + + websiteLink.href = designer.website; + websiteLink.textContent = designer.website; + websiteLink.target = "_blank"; + websiteLink.rel = "noopener noreferrer"; + + websiteContainer.append(websiteLink); + } else { + websiteContainer.textContent = + "No website is available."; + } + document.querySelector("#designer-details").textContent = details.join(" · "); From b664df7a9e50593b1076d3d7fb873b26a8b78ef0 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 14:09:29 -0400 Subject: [PATCH 36/56] Add designer deletion control --- web/designer.html | 4 ++++ web/designer.js | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/web/designer.html b/web/designer.html index 5a4aa04..d56d379 100644 --- a/web/designer.html +++ b/web/designer.html @@ -34,6 +34,10 @@

        Collections

        Edit designer

        +

        +

          diff --git a/web/designer.js b/web/designer.js index a218d6b..e747f7a 100644 --- a/web/designer.js +++ b/web/designer.js @@ -89,6 +89,39 @@ async function loadDesigner() { status.textContent = ""; profile.hidden = false; + + const deleteButton = + document.querySelector("#delete-designer"); + + deleteButton.addEventListener("click", async function () { + const confirmed = window.confirm( + "Delete this designer and all of their collections permanently?" + ); + + if (!confirmed) { + return; + } + + try { + const deleteResponse = await fetch( + `/designers/${designerId}`, + { + method: "DELETE", + } + ); + + if (!deleteResponse.ok) { + throw new Error( + "Designer could not be deleted" + ); + } + + window.location.href = "/"; + } catch (error) { + status.textContent = error.message; + } + }); + } catch (error) { status.textContent = "This designer profile could not be loaded."; From 77db3d10a6883cb7a124e7ca56c2f06df87a70bc Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 14:58:52 -0400 Subject: [PATCH 37/56] Document Collection Archive architecture and setup --- README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/README.md b/README.md index 7b04165..9731080 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,51 @@ +## Collection Archive + +### What this product does: + +A public-facing archive that helps people discover which individual designers created collections for different fashion labels throughout their careers. +Users can add/edit designer/collection records (think Wiki) +Visitors can view archived designers to discover who created certain fashion collections, view collections credited to a designer across different labels and seasons. + +### Data relationship + +One designer may have many collections across many labels/seasons. (OneToMany) Eventually I think I should include a junction table to represent collections credited to multiple designers. (ManyToMany) + +### Technology + +- SQLite +- Python +- FastAPI +- Vanilla HTML, CSS, and JavaScript +- Pytest + +### Run locally + +```bash +python3 -m venv .venv +source .venv/bin/activate +python3 -m pip install -r requirements.txt +python3 scripts/init_db.py +uvicorn app.main:app --reload + +### Application Structure + +sql/schema.sql + #schema.sql defines the database tables, fields, constraints, foreign key, and index. +sql/seed.sql + #seed.sql contains repeatable sample records. +scripts/init_db.py + #scripts/init_db.py recreates the database and executes both SQL files. +app/database.py + #database.py opens and configures connections used during normal API reads and writes. +app/schemas.py + #schemas.py defines the accepted structure and validation rules for designer and collection data received by the API. The SQL tables remain defined separately in schema.sql. +app/main.py + #main.py defines the middle-tier FastAPI application. Uvicorn receives HTTP requests and passes them to matching FastAPI routes. Those routes validate requests, run SQL through a database connection, and return data or errors to the frontend as HTTP responses. +web/ + #The web/ directory contains the user-facing layer. HTML defines the structure and content of each page, CSS controls its visual presentation, and JavaScript loads archive data, handles forms, and communicates with the API. +tests/ + #The tests verify API functionality by sending predefined input and comparing the response with expected output. Each test uses a temporary database so the real archive data is not changed. + # OnesToManys (ListDetails) The point of this project is to explore what a 3-tier web application is like. From 7c900cc319c4b5ac3051c4b8fe04d6083831ff61 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 15:28:48 -0400 Subject: [PATCH 38/56] Expand and correct archive seed data --- sql/seed.sql | 172 +++++++++++++++++++++++++++++++++++++++++++--- tests/test_api.py | 32 +++++++-- 2 files changed, 188 insertions(+), 16 deletions(-) diff --git a/sql/seed.sql b/sql/seed.sql index 1602fc8..5a49c7f 100644 --- a/sql/seed.sql +++ b/sql/seed.sql @@ -24,6 +24,70 @@ VALUES NULL, NULL, 'Fashion designer associated with multiple labels and creative projects.' + ), + ( + 3, + 'Grace Wales Bonner', + 'British-Jamaican', + NULL, + 'https://walesbonner.com', + 'Founder of Wales Bonner, a label exploring European heritage and Afro-Atlantic cultural traditions.' + ), + ( + 4, + 'Lee Alexander McQueen', + 'British', + NULL, + 'https://www.alexandermcqueen.com', + 'Founder of the McQueen house, known for innovative tailoring and theatrical presentations.' + ), + ( + 5, + 'Jonathan Anderson', + 'Northern Irish', + NULL, + 'https://jwanderson.com', + 'Founder of JW Anderson whose career includes creative leadership at Loewe and Dior.' + ), + ( + 6, + 'Demna', + 'Georgian', + NULL, + NULL, + 'Designer and co-founder of Vetements whose career includes creative leadership at Balenciaga and Gucci.' + ), + ( + 7, + 'Virgil Abloh', + 'American', + NULL, + NULL, + 'Designer and founder of Off-White whose career includes creative leadership at Louis Vuitton.' + ), + ( + 8, + 'Hussein Chalayan', + 'Cypriot-British', + NULL, + NULL, + 'Designer known for conceptual and technology-driven fashion.' + ), + ( + 9, + 'Miuccia Prada', + 'Italian', + NULL, + NULL, + 'Designer and creative director of Prada and Miu Miu.' + ), + ( + 10, + 'Rei Kawakubo', + 'Japanese', + NULL, + NULL, + 'Designer and founder of Comme des Garçons.' ); @@ -39,13 +103,101 @@ INSERT INTO collections ( description ) VALUES ( - 1, - 1, - 'Givenchy', - NULL, - 'Fall/Winter', - 2025, - 'released', - 52, - 'The collection focused on cut, proportion, and tailoring.' -); + 1, + 1, + 'Givenchy', + NULL, + 'Fall/Winter', + 2025, + 'released', + 52, + 'The collection focused on cut, proportion, and tailoring.' + ), + ( + 2, + 1, + 'Alexander McQueen', + NULL, + 'Spring/Summer', + 2024, + 'archived', + NULL, + 'Sarah Burton''s final collection as creative director of Alexander McQueen.' + ), + ( + 3, + 2, + 'Hood By Air', + 'Pilgrimage', + 'Fall/Winter', + 2016, + 'archived', + NULL, + 'A collection exploring transience, migration, and the body as cargo.' + ), + ( + 4, + 3, + 'Wales Bonner', + NULL, + 'Spring/Summer', + 2024, + 'released', + 45, + 'The collection celebrated Afro-Atlantic cultural traditions through contemporary fashion.' + ), + ( + 5, + 4, + 'Alexander McQueen', + 'No. 13', + 'Spring/Summer', + 1999, + 'archived', + NULL, + 'A landmark Lee Alexander McQueen collection known for its theatrical runway presentation.' + ), + ( + 6, + 5, + 'JW Anderson', + NULL, + 'Spring/Summer', + 2024, + 'released', + 55, + 'The collection explored gender fluidity and contemporary design.' + ), + ( + 7, + 2, + 'Hood By Air', + 'Wench', + 'Spring/Summer', + 2017, + 'archived', + NULL, + 'A Hood By Air collection presented during New York Fashion Week.' + ), + ( + 8, + 2, + 'Helmut Lang', + 'Seen by Shayne Oliver', + 'Spring/Summer', + 2018, + 'archived', + NULL, + 'Created during Shayne Oliver''s residency at Helmut Lang.' + ), + ( + 9, + 2, + 'Diesel', + 'Red Tag Project', + 'Fall/Winter', + 2018, + 'archived', + NULL, + 'A denim-focused capsule created for the Diesel Red Tag Project.' + ); diff --git a/tests/test_api.py b/tests/test_api.py index 0e6515e..5564d61 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -47,9 +47,23 @@ def test_list_designers(client): designers = response.json() - assert len(designers) == 2 - assert designers[0]["full_name"] == "Sarah Burton" - assert designers[1]["full_name"] == "Shayne Oliver" + assert len(designers) == 10 + designer_names = { + designer["full_name"] + for designer in designers + } + assert designer_names == { + "Demna", + "Grace Wales Bonner", + "Hussein Chalayan", + "Jonathan Anderson", + "Lee Alexander McQueen", + "Miuccia Prada", + "Rei Kawakubo", + "Sarah Burton", + "Shayne Oliver", + "Virgil Abloh", + } def test_get_designer(client): response = client.get("/designers/1") @@ -74,9 +88,15 @@ def test_list_collections_for_designer(client): collections = response.json() - assert len(collections) == 1 - assert collections[0]["label"] == "Givenchy" - assert collections[0]["designer_id"] == 1 + assert len(collections) == 2 + assert all( + collection["designer_id"] == 1 + for collection in collections + ) + assert { + collection["label"] + for collection in collections + } == {"Alexander McQueen", "Givenchy"} def test_deleting_designer_cascades_to_collections(client): designer_response = client.post( From 219523d8a6df31b8dcec8c5ec88f8706abdc61dd Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 15:34:54 -0400 Subject: [PATCH 39/56] Make database initialization non-destructive --- README.md | 6 +++++- scripts/init_db.py | 31 ++++++++++++++++++++----------- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9731080..c969a54 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ source .venv/bin/activate python3 -m pip install -r requirements.txt python3 scripts/init_db.py uvicorn app.main:app --reload +``` + +`init_db.py` creates and seeds `data/archive.db` only when the database does +not already exist. It never overwrites live archive records. ### Application Structure @@ -34,7 +38,7 @@ sql/schema.sql sql/seed.sql #seed.sql contains repeatable sample records. scripts/init_db.py - #scripts/init_db.py recreates the database and executes both SQL files. + #init_db.py creates and seeds the database only when it does not exist. app/database.py #database.py opens and configures connections used during normal API reads and writes. app/schemas.py diff --git a/scripts/init_db.py b/scripts/init_db.py index 8541e2d..c31717e 100644 --- a/scripts/init_db.py +++ b/scripts/init_db.py @@ -8,20 +8,29 @@ SEED_PATH = PROJECT_ROOT / "sql" / "seed.sql" -DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True) +def initialize_database() -> bool: + """Create and seed the archive database only when it does not exist.""" + DATABASE_PATH.parent.mkdir(parents=True, exist_ok=True) -if DATABASE_PATH.exists(): - DATABASE_PATH.unlink() + if DATABASE_PATH.exists(): + return False -connection = sqlite3.connect(DATABASE_PATH) -connection.execute("PRAGMA foreign_keys = ON") + connection = sqlite3.connect(DATABASE_PATH) -schema_sql = SCHEMA_PATH.read_text() -connection.executescript(schema_sql) + try: + connection.execute("PRAGMA foreign_keys = ON") + connection.executescript(SCHEMA_PATH.read_text()) + connection.executescript(SEED_PATH.read_text()) + finally: + connection.close() -seed_sql = SEED_PATH.read_text() -connection.executescript(seed_sql) + return True -connection.close() -print(f"Database initialized at {DATABASE_PATH}") +if __name__ == "__main__": + created = initialize_database() + + if created: + print(f"Database initialized at {DATABASE_PATH}") + else: + print(f"Database already exists; left unchanged at {DATABASE_PATH}") From 69ed546b107268010e3ac940168e9771d69db046 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 19:05:48 -0400 Subject: [PATCH 40/56] Replace Hussein Chalayan with Junya Watanabe --- sql/seed.sql | 8 ++++---- tests/test_api.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sql/seed.sql b/sql/seed.sql index 5a49c7f..efa6409 100644 --- a/sql/seed.sql +++ b/sql/seed.sql @@ -67,11 +67,11 @@ VALUES ), ( 8, - 'Hussein Chalayan', - 'Cypriot-British', - NULL, + 'Junya Watanabe', + 'Japanese', + 1961, NULL, - 'Designer known for conceptual and technology-driven fashion.' + 'Designer who began his career at Comme des Garçons and launched his namesake line within the house.' ), ( 9, diff --git a/tests/test_api.py b/tests/test_api.py index 5564d61..68806bb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -55,7 +55,7 @@ def test_list_designers(client): assert designer_names == { "Demna", "Grace Wales Bonner", - "Hussein Chalayan", + "Junya Watanabe", "Jonathan Anderson", "Lee Alexander McQueen", "Miuccia Prada", From 1aa956c5aed02c808a3e1925910f99ca8df1086f Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 19:19:33 -0400 Subject: [PATCH 41/56] Expand and audit collection seed data --- sql/seed.sql | 79 ++++++++++++++++++++++++++++++++++++++++++++--- tests/test_api.py | 19 ++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/sql/seed.sql b/sql/seed.sql index efa6409..a4e48f0 100644 --- a/sql/seed.sql +++ b/sql/seed.sql @@ -143,8 +143,8 @@ VALUES ( 'Spring/Summer', 2024, 'released', - 45, - 'The collection celebrated Afro-Atlantic cultural traditions through contemporary fashion.' + 34, + 'The Spring/Summer 2024 menswear collection was titled Marathon.' ), ( 5, @@ -165,8 +165,8 @@ VALUES ( 'Spring/Summer', 2024, 'released', - 55, - 'The collection explored gender fluidity and contemporary design.' + NULL, + 'The collection reworked familiar wardrobe pieces with exaggerated proportions and unexpected materials.' ), ( 7, @@ -177,7 +177,7 @@ VALUES ( 2017, 'archived', NULL, - 'A Hood By Air collection presented during New York Fashion Week.' + 'A Hood By Air collection developed with Wench, Shayne Oliver and Arca''s musical project.' ), ( 8, @@ -201,3 +201,72 @@ VALUES ( NULL, 'A denim-focused capsule created for the Diesel Red Tag Project.' ); + + +INSERT INTO collections ( + id, + designer_id, + label, + name, + season, + release_year, + status, + piece_count, + description +) +VALUES + ( + 10, + 6, + 'Balenciaga', + NULL, + 'Spring/Summer', + 2023, + 'archived', + NULL, + 'A Demna collection presented on a mud-covered runway in Paris.' + ), + ( + 11, + 7, + 'Louis Vuitton', + NULL, + 'Spring/Summer', + 2019, + 'archived', + NULL, + 'Virgil Abloh''s debut menswear collection for Louis Vuitton.' + ), + ( + 12, + 8, + 'Junya Watanabe MAN', + NULL, + 'Spring/Summer', + 2025, + 'archived', + NULL, + 'A menswear collection combining formalwear with a punk sensibility.' + ), + ( + 13, + 9, + 'Prada', + NULL, + 'Spring/Summer', + 2012, + 'archived', + NULL, + 'A Miuccia Prada collection drawing on 1950s automobile imagery.' + ), + ( + 14, + 10, + 'Comme des Garçons', + 'Body Meets Dress, Dress Meets Body', + 'Spring/Summer', + 1997, + 'archived', + NULL, + 'Rei Kawakubo challenged conventional silhouettes using asymmetrical padded forms.' + ); diff --git a/tests/test_api.py b/tests/test_api.py index 68806bb..7294cd2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -98,6 +98,25 @@ def test_list_collections_for_designer(client): for collection in collections } == {"Alexander McQueen", "Givenchy"} + +def test_every_seeded_designer_has_a_collection(client): + designers_response = client.get("/designers") + collections_response = client.get("/collections") + + assert designers_response.status_code == 200 + assert collections_response.status_code == 200 + + designer_ids = { + designer["id"] + for designer in designers_response.json() + } + credited_designer_ids = { + collection["designer_id"] + for collection in collections_response.json() + } + + assert designer_ids <= credited_designer_ids + def test_deleting_designer_cascades_to_collections(client): designer_response = client.post( "/designers", From c4c77ba2ecf80c3f05b9e5a6e0d385df817f4af8 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 19:57:26 -0400 Subject: [PATCH 42/56] Add Anonymous Club and Telfar seed records --- sql/seed.sql | 30 ++++++++++++++++++++++++++++++ tests/test_api.py | 3 ++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/sql/seed.sql b/sql/seed.sql index a4e48f0..f3b23fa 100644 --- a/sql/seed.sql +++ b/sql/seed.sql @@ -88,6 +88,14 @@ VALUES NULL, NULL, 'Designer and founder of Comme des Garçons.' + ), + ( + 11, + 'Telfar Clemens', + 'Liberian-American', + 1985, + 'https://telfar.net', + 'Founder of Telfar, a New York label known for accessible, unisex fashion and its community-focused approach.' ); @@ -269,4 +277,26 @@ VALUES 'archived', NULL, 'Rei Kawakubo challenged conventional silhouettes using asymmetrical padded forms.' + ), + ( + 15, + 2, + 'Anonymous Club', + NULL, + 'Resort', + 2024, + 'archived', + NULL, + 'The second Anonymous Club installment presented wardrobe staples through Shayne Oliver''s design language.' + ), + ( + 16, + 11, + 'Telfar', + NULL, + 'Spring/Summer', + 2020, + 'archived', + NULL, + 'A Paris presentation pairing the collection with the collaborative film The World Isn''t Everything.' ); diff --git a/tests/test_api.py b/tests/test_api.py index 7294cd2..39835fe 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -47,7 +47,7 @@ def test_list_designers(client): designers = response.json() - assert len(designers) == 10 + assert len(designers) == 11 designer_names = { designer["full_name"] for designer in designers @@ -62,6 +62,7 @@ def test_list_designers(client): "Rei Kawakubo", "Sarah Burton", "Shayne Oliver", + "Telfar Clemens", "Virgil Abloh", } From 0238ead6456900316948c8aed829f339524d6849 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 20:01:10 -0400 Subject: [PATCH 43/56] Add Rick Owens seed record --- sql/seed.sql | 19 +++++++++++++++++++ tests/test_api.py | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/sql/seed.sql b/sql/seed.sql index f3b23fa..ba36abf 100644 --- a/sql/seed.sql +++ b/sql/seed.sql @@ -96,6 +96,14 @@ VALUES 1985, 'https://telfar.net', 'Founder of Telfar, a New York label known for accessible, unisex fashion and its community-focused approach.' + ), + ( + 12, + 'Rick Owens', + 'American', + 1962, + 'https://www.rickowens.eu', + 'California-born designer who founded his independent namesake label in 1994 and later established it in Paris.' ); @@ -299,4 +307,15 @@ VALUES 'archived', NULL, 'A Paris presentation pairing the collection with the collaborative film The World Isn''t Everything.' + ), + ( + 17, + 12, + 'Rick Owens', + 'Vicious', + 'Spring/Summer', + 2014, + 'archived', + 40, + 'A presentation performed by four step teams that challenged conventional runway casting and beauty standards.' ); diff --git a/tests/test_api.py b/tests/test_api.py index 39835fe..57d0f4a 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -47,7 +47,7 @@ def test_list_designers(client): designers = response.json() - assert len(designers) == 11 + assert len(designers) == 12 designer_names = { designer["full_name"] for designer in designers @@ -60,6 +60,7 @@ def test_list_designers(client): "Lee Alexander McQueen", "Miuccia Prada", "Rei Kawakubo", + "Rick Owens", "Sarah Burton", "Shayne Oliver", "Telfar Clemens", From a81b2009bac6ae880610470826b36f5c61aea2a8 Mon Sep 17 00:00:00 2001 From: hakeem Date: Fri, 14 Aug 2026 21:57:48 -0400 Subject: [PATCH 44/56] Add React UI and database migrations --- README.md | 32 +- app/database.py | 47 + app/main.py | 13 +- react-ui/.gitignore | 24 + react-ui/.oxlintrc.json | 8 + react-ui/README.md | 28 + react-ui/index.html | 14 + react-ui/package-lock.json | 1352 +++++++++++++++++++ react-ui/package.json | 24 + react-ui/public/favicon.svg | 1 + react-ui/public/icons.svg | 24 + react-ui/src/App.css | 10 + react-ui/src/App.jsx | 24 + react-ui/src/api.js | 14 + react-ui/src/assets/hero.png | Bin 0 -> 13057 bytes react-ui/src/assets/react.svg | 1 + react-ui/src/assets/vite.svg | 1 + react-ui/src/components/Layout.jsx | 5 + react-ui/src/components/StatusMessage.jsx | 4 + react-ui/src/index.css | 6 + react-ui/src/main.jsx | 10 + react-ui/src/pages/CollectionDetail.jsx | 12 + react-ui/src/pages/CollectionForm.jsx | 14 + react-ui/src/pages/DesignerDetail.jsx | 18 + react-ui/src/pages/DesignerForm.jsx | 14 + react-ui/src/pages/DesignerList.jsx | 12 + react-ui/src/pages/NotFound.jsx | 2 + react-ui/vite.config.js | 17 + sql/migrations/001_sync_archive_records.sql | 136 ++ sql/seed.sql | 16 +- tests/test_api.py | 25 + 31 files changed, 1884 insertions(+), 24 deletions(-) create mode 100644 react-ui/.gitignore create mode 100644 react-ui/.oxlintrc.json create mode 100644 react-ui/README.md create mode 100644 react-ui/index.html create mode 100644 react-ui/package-lock.json create mode 100644 react-ui/package.json create mode 100644 react-ui/public/favicon.svg create mode 100644 react-ui/public/icons.svg create mode 100644 react-ui/src/App.css create mode 100644 react-ui/src/App.jsx create mode 100644 react-ui/src/api.js create mode 100644 react-ui/src/assets/hero.png create mode 100644 react-ui/src/assets/react.svg create mode 100644 react-ui/src/assets/vite.svg create mode 100644 react-ui/src/components/Layout.jsx create mode 100644 react-ui/src/components/StatusMessage.jsx create mode 100644 react-ui/src/index.css create mode 100644 react-ui/src/main.jsx create mode 100644 react-ui/src/pages/CollectionDetail.jsx create mode 100644 react-ui/src/pages/CollectionForm.jsx create mode 100644 react-ui/src/pages/DesignerDetail.jsx create mode 100644 react-ui/src/pages/DesignerForm.jsx create mode 100644 react-ui/src/pages/DesignerList.jsx create mode 100644 react-ui/src/pages/NotFound.jsx create mode 100644 react-ui/vite.config.js create mode 100644 sql/migrations/001_sync_archive_records.sql diff --git a/README.md b/README.md index c969a54..75993c3 100644 --- a/README.md +++ b/README.md @@ -31,24 +31,44 @@ uvicorn app.main:app --reload `init_db.py` creates and seeds `data/archive.db` only when the database does not already exist. It never overwrites live archive records. +After initialization, designers and collections added through either web UI +are stored in that same live database and appear in both interfaces. SQL files +under `sql/migrations/` contain deliberate database upgrades. FastAPI applies +each migration once at startup and records it in `schema_migrations`; migrations +never recreate the database from seed data. + ### Application Structure sql/schema.sql - #schema.sql defines the database tables, fields, constraints, foreign key, and index. + #Schema.sql defines the database tables, fields, constraints, foreign key, and index. sql/seed.sql - #seed.sql contains repeatable sample records. + #Seed.sql supplies starting data only when a database is first created. Existing databases may contain user additions, so rebuilding them from the seed would cause data loss. The migration system safely upgrades existing databases. FastAPI checks a migration-history table during startup, applies each pending numbered SQL file in a transaction, and records it so it cannot run twice. Normal user additions still go through the API and are immediately visible in both frontends. scripts/init_db.py - #init_db.py creates and seeds the database only when it does not exist. + #Init_db.py creates and seeds the database only when it does not exist. app/database.py - #database.py opens and configures connections used during normal API reads and writes. + #Database.py opens and configures connections used during normal API reads and writes. app/schemas.py - #schemas.py defines the accepted structure and validation rules for designer and collection data received by the API. The SQL tables remain defined separately in schema.sql. + #Schemas.py defines the accepted structure and validation rules for designer and collection data received by the API. The SQL tables remain defined separately in schema.sql. app/main.py - #main.py defines the middle-tier FastAPI application. Uvicorn receives HTTP requests and passes them to matching FastAPI routes. Those routes validate requests, run SQL through a database connection, and return data or errors to the frontend as HTTP responses. + #Main.py defines the middle-tier FastAPI application. Uvicorn receives HTTP requests and passes them to matching FastAPI routes. Those routes validate requests, run SQL through a database connection, and return data or errors to the frontend as HTTP responses. web/ #The web/ directory contains the user-facing layer. HTML defines the structure and content of each page, CSS controls its visual presentation, and JavaScript loads archive data, handles forms, and communicates with the API. tests/ #The tests verify API functionality by sending predefined input and comparing the response with expected output. Each test uses a temporary database so the real archive data is not changed. +react-ui/app.jsx + #App.jsx is the React routing map. It connects browser URLs to page components, places those pages inside a shared layout, and includes routes for listing, viewing, creating, editing, and handling unknown pages. +react-ui/designerlist.jsx + #DesignerList.jsx requests designers from FastAPI when it first loads, stores the result in React state, and maps each designer record into a linked card on the home page. +react-ui/designerdetail.jsx + #DesignerDetail reads a designer ID from the React route. When the component loads, it requests both the designer record and that designer’s collections from FastAPI. It stores both responses in React state and renders the one-to-many relationship. It also links to the create and edit forms. When a deletion is confirmed, it sends a DELETE request and SQLite performs the cascading collection deletion. +react-ui/designerform.jsx + #DesignerForm handles both creating and editing designers. It detects edit mode from the route parameter. Its inputs are controlled by one state object, and a shared change handler updates the relevant property. On submission, it converts form strings into the types expected by FastAPI, changes blank optional fields to null, normalizes the website address, and sends either POST or PUT. After a successful response, it navigates to the saved designer’s profile. +react-ui/collectiondetail.jsx + #CollectionDetail gets the collection ID from the React route and requests that record from FastAPI. The API response includes the collection’s foreign key and the designer name obtained through a SQL join. The component displays optional fields with appropriate fallbacks and links back to the parent designer. It can also navigate to the edit form or delete the collection and return to its designer’s profile. +react-ui/collectionform.jsx + #CollectionForm handles both collection creation and editing. When creating, it obtains the parent designer ID from the nested URL. When editing, it obtains the designer ID from the existing collection. Its controlled fields are stored in React state, and submission converts the string input values into the integer and null values expected by FastAPI. The payload includes designer_id, which connects the collection to its parent. FastAPI validates the parent, while SQLite enforces the foreign key and uniqueness rules. +react-us/api.js + #Api.js centralizes communication between React and FastAPI. It prefixes API requests so Vite can proxy them to the backend, adds the JSON content header when a request has a body, parses successful JSON responses, handles empty deletion responses, and converts unsuccessful HTTP responses into JavaScript errors that page components can display. # OnesToManys (ListDetails) diff --git a/app/database.py b/app/database.py index 6d4c784..18ea2c2 100644 --- a/app/database.py +++ b/app/database.py @@ -4,6 +4,7 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent DATABASE_PATH = PROJECT_ROOT / "data" / "archive.db" +MIGRATIONS_PATH = PROJECT_ROOT / "sql" / "migrations" def connect() -> sqlite3.Connection: @@ -11,3 +12,49 @@ def connect() -> sqlite3.Connection: connection.row_factory = sqlite3.Row connection.execute("PRAGMA foreign_keys = ON") return connection + + +def apply_migrations() -> list[str]: + """Apply each pending SQL migration exactly once.""" + connection = connect() + applied = [] + + try: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + + completed = { + row["filename"] + for row in connection.execute( + "SELECT filename FROM schema_migrations" + ).fetchall() + } + + for migration_path in sorted(MIGRATIONS_PATH.glob("*.sql")): + if migration_path.name in completed: + continue + + migration_sql = migration_path.read_text() + quoted_filename = migration_path.name.replace("'", "''") + + connection.executescript( + "BEGIN IMMEDIATE;\n" + f"{migration_sql}\n" + "INSERT INTO schema_migrations (filename) " + f"VALUES ('{quoted_filename}');\n" + "COMMIT;" + ) + applied.append(migration_path.name) + except Exception: + connection.rollback() + raise + finally: + connection.close() + + return applied diff --git a/app/main.py b/app/main.py index 76376ed..baae1ef 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,18 @@ import sqlite3 +from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Response, status -from app.database import connect +from app.database import apply_migrations, connect from app.schemas import CollectionCreate, DesignerCreate from fastapi.staticfiles import StaticFiles -app = FastAPI(title="Collection Archive") +@asynccontextmanager +async def lifespan(_app: FastAPI): + apply_migrations() + yield + + +app = FastAPI(title="Collection Archive", lifespan=lifespan) @app.get("/health") @@ -559,4 +566,4 @@ def list_collections(): "/", StaticFiles(directory="web", html=True), name="web", -) \ No newline at end of file +) diff --git a/react-ui/.gitignore b/react-ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/react-ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/react-ui/.oxlintrc.json b/react-ui/.oxlintrc.json new file mode 100644 index 0000000..1255078 --- /dev/null +++ b/react-ui/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/react-ui/README.md b/react-ui/README.md new file mode 100644 index 0000000..cede323 --- /dev/null +++ b/react-ui/README.md @@ -0,0 +1,28 @@ +# Collection Archive React UI + +This client recreates the CRUD features in `web/` with React. During +development, Vite forwards requests beginning with `/api` to the FastAPI +server at `http://127.0.0.1:8000`. + +Run FastAPI from the repository root: + +```bash +.venv/bin/uvicorn app.main:app --reload +``` + +In a second terminal, run React: + +```bash +cd react-ui +npm install +npm run dev +``` + +Open `http://127.0.0.1:5173`. + +Useful checks: + +```bash +npm run lint +npm run build +``` diff --git a/react-ui/index.html b/react-ui/index.html new file mode 100644 index 0000000..b0cd890 --- /dev/null +++ b/react-ui/index.html @@ -0,0 +1,14 @@ + + + + + + + + Collection Archive + + +
          + + + diff --git a/react-ui/package-lock.json b/react-ui/package-lock.json new file mode 100644 index 0000000..9c4635c --- /dev/null +++ b/react-ui/package-lock.json @@ -0,0 +1,1352 @@ +{ + "name": "react-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-ui", + "version": "0.0.0", + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "oxlint": "^1.75.0", + "vite": "^8.2.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.78.0.tgz", + "integrity": "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.78.0.tgz", + "integrity": "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.78.0.tgz", + "integrity": "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.78.0.tgz", + "integrity": "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.78.0.tgz", + "integrity": "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.78.0.tgz", + "integrity": "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.78.0.tgz", + "integrity": "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.78.0.tgz", + "integrity": "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.78.0.tgz", + "integrity": "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.78.0.tgz", + "integrity": "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.78.0.tgz", + "integrity": "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.78.0.tgz", + "integrity": "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.78.0.tgz", + "integrity": "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.78.0.tgz", + "integrity": "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.78.0.tgz", + "integrity": "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.78.0.tgz", + "integrity": "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.78.0.tgz", + "integrity": "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.78.0.tgz", + "integrity": "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.78.0.tgz", + "integrity": "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.78.0.tgz", + "integrity": "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.78.0", + "@oxlint/binding-android-arm64": "1.78.0", + "@oxlint/binding-darwin-arm64": "1.78.0", + "@oxlint/binding-darwin-x64": "1.78.0", + "@oxlint/binding-freebsd-x64": "1.78.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", + "@oxlint/binding-linux-arm-musleabihf": "1.78.0", + "@oxlint/binding-linux-arm64-gnu": "1.78.0", + "@oxlint/binding-linux-arm64-musl": "1.78.0", + "@oxlint/binding-linux-ppc64-gnu": "1.78.0", + "@oxlint/binding-linux-riscv64-gnu": "1.78.0", + "@oxlint/binding-linux-riscv64-musl": "1.78.0", + "@oxlint/binding-linux-s390x-gnu": "1.78.0", + "@oxlint/binding-linux-x64-gnu": "1.78.0", + "@oxlint/binding-linux-x64-musl": "1.78.0", + "@oxlint/binding-openharmony-arm64": "1.78.0", + "@oxlint/binding-win32-arm64-msvc": "1.78.0", + "@oxlint/binding-win32-ia32-msvc": "1.78.0", + "@oxlint/binding-win32-x64-msvc": "1.78.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/react-ui/package.json b/react-ui/package.json new file mode 100644 index 0000000..15bf6b8 --- /dev/null +++ b/react-ui/package.json @@ -0,0 +1,24 @@ +{ + "name": "react-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2" + }, + "devDependencies": { + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "oxlint": "^1.75.0", + "vite": "^8.2.0" + } +} diff --git a/react-ui/public/favicon.svg b/react-ui/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/react-ui/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react-ui/public/icons.svg b/react-ui/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/react-ui/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/react-ui/src/App.css b/react-ui/src/App.css new file mode 100644 index 0000000..73042e1 --- /dev/null +++ b/react-ui/src/App.css @@ -0,0 +1,10 @@ +.site-header { padding: 2rem clamp(1.25rem, 5vw, 5rem); border-bottom: 1px solid #c9c0b3; display: flex; align-items: baseline; justify-content: space-between; gap: 2rem; } +.site-header p { margin: 0; color: #685f56; }.brand { font-family: "Playfair Display", serif; font-size: 1.45rem; text-decoration: none; } +.page { width: min(1080px, calc(100% - 2.5rem)); margin: 0 auto; padding: 4rem 0 6rem; } h1, h2 { font-family: "Playfair Display", serif; } h1 { font-size: clamp(2.6rem, 7vw, 5.5rem); line-height: .95; margin: .3rem 0 1.5rem; } +.eyebrow { text-transform: uppercase; letter-spacing: .14em; font-size: .78rem; font-weight: 600; color: #7d3428; }.page-heading { display: flex; justify-content: space-between; align-items: end; margin-bottom: 2.5rem; gap: 2rem; }.page-heading h1 { margin-bottom: 0; } +.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1px; background: #c9c0b3; border: 1px solid #c9c0b3; }.card { background: #f5f1e9; padding: 1.75rem; min-height: 150px; }.card h2 { margin-top: 0; }.card p, .meta { color: #685f56; } +.button, button { display: inline-block; border: 1px solid #211d1a; background: #211d1a; color: #fff; padding: .75rem 1rem; text-decoration: none; cursor: pointer; }.button.secondary { color: #211d1a; background: transparent; }.danger { background: #8b2e23; border-color: #8b2e23; }.actions { display: flex; flex-wrap: wrap; gap: .75rem; margin: 2rem 0; } +.section { border-top: 1px solid #c9c0b3; margin-top: 3rem; padding-top: 1.5rem; }.collection-list { list-style: none; padding: 0; border-top: 1px solid #c9c0b3; }.collection-list li { border-bottom: 1px solid #c9c0b3; }.collection-list a { display: flex; justify-content: space-between; gap: 2rem; padding: 1.2rem 0; text-decoration: none; }.collection-list span { color: #685f56; text-align: right; } +.record-form { display: grid; gap: 1.2rem; max-width: 680px; }.record-form label { display: grid; gap: .45rem; font-weight: 600; }.record-form input, .record-form select, .record-form textarea { width: 100%; border: 1px solid #9f9588; background: #fffdf8; color: #211d1a; padding: .8rem; }.record-form textarea { resize: vertical; }.record-form .button { justify-self: start; } +.status { padding: 1rem; background: #e9e1d5; }.status.error { color: #761f17; border-left: 4px solid #8b2e23; }.details { display: flex; gap: 3rem; margin: 2rem 0; }.details dt { color: #685f56; font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; }.details dd { margin: .35rem 0 0; } +@media (max-width: 650px) { .site-header { align-items: flex-start; flex-direction: column; } .page-heading, .collection-list a { align-items: flex-start; flex-direction: column; } .collection-list span { text-align: left; } } diff --git a/react-ui/src/App.jsx b/react-ui/src/App.jsx new file mode 100644 index 0000000..c1c419e --- /dev/null +++ b/react-ui/src/App.jsx @@ -0,0 +1,24 @@ +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import Layout from "./components/Layout"; +import CollectionDetail from "./pages/CollectionDetail"; +import CollectionForm from "./pages/CollectionForm"; +import DesignerDetail from "./pages/DesignerDetail"; +import DesignerForm from "./pages/DesignerForm"; +import DesignerList from "./pages/DesignerList"; +import NotFound from "./pages/NotFound"; +import "./App.css"; + +export default function App() { + return ( + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} diff --git a/react-ui/src/api.js b/react-ui/src/api.js new file mode 100644 index 0000000..390ec47 --- /dev/null +++ b/react-ui/src/api.js @@ -0,0 +1,14 @@ +const API_ROOT = "/api"; + +export async function apiRequest(path, options = {}) { + const response = await fetch(`${API_ROOT}${path}`, { + ...options, + headers: { ...(options.body ? { "Content-Type": "application/json" } : {}), ...options.headers }, + }); + if (response.status === 204) return null; + const result = await response.json(); + if (!response.ok) { + throw new Error(typeof result.detail === "string" ? result.detail : "The request could not be completed."); + } + return result; +} diff --git a/react-ui/src/assets/hero.png b/react-ui/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/react-ui/src/assets/react.svg b/react-ui/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/react-ui/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/react-ui/src/assets/vite.svg b/react-ui/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/react-ui/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/react-ui/src/components/Layout.jsx b/react-ui/src/components/Layout.jsx new file mode 100644 index 0000000..9eefbff --- /dev/null +++ b/react-ui/src/components/Layout.jsx @@ -0,0 +1,5 @@ +import { Link, Outlet } from "react-router-dom"; + +export default function Layout() { + return <>
          Collection Archive

          Discover the designers behind collections across labels and seasons.

          ; +} diff --git a/react-ui/src/components/StatusMessage.jsx b/react-ui/src/components/StatusMessage.jsx new file mode 100644 index 0000000..91495b0 --- /dev/null +++ b/react-ui/src/components/StatusMessage.jsx @@ -0,0 +1,4 @@ +export default function StatusMessage({ children, error = false }) { + if (!children) return null; + return

          {children}

          ; +} diff --git a/react-ui/src/index.css b/react-ui/src/index.css new file mode 100644 index 0000000..ebc8839 --- /dev/null +++ b/react-ui/src/index.css @@ -0,0 +1,6 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&family=Playfair+Display:wght@600&display=swap'); +:root { font-family: "DM Sans", sans-serif; color: #211d1a; background: #f5f1e9; font-synthesis: none; } +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; min-height: 100vh; } +a { color: inherit; text-underline-offset: .2em; } +button, input, select, textarea { font: inherit; } diff --git a/react-ui/src/main.jsx b/react-ui/src/main.jsx new file mode 100644 index 0000000..b9a1a6d --- /dev/null +++ b/react-ui/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/react-ui/src/pages/CollectionDetail.jsx b/react-ui/src/pages/CollectionDetail.jsx new file mode 100644 index 0000000..c859d4b --- /dev/null +++ b/react-ui/src/pages/CollectionDetail.jsx @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { apiRequest } from "../api"; +import StatusMessage from "../components/StatusMessage"; + +export default function CollectionDetail() { + const { collectionId } = useParams(); const navigate = useNavigate(); const [collection, setCollection] = useState(null); const [error, setError] = useState(""); + useEffect(() => { apiRequest(`/collections/${collectionId}`).then(setCollection).catch((error) => setError(error.message)); }, [collectionId]); + async function deleteCollection() { if (!window.confirm("Delete this collection permanently?")) return; try { await apiRequest(`/collections/${collectionId}`, { method: "DELETE" }); navigate(`/designers/${collection.designer_id}`); } catch (error) { setError(error.message); } } + if (error && !collection) return {error}; if (!collection) return Loading collection…; + return

          {collection.label}

          {collection.name || `${collection.season} ${collection.release_year}`}

          {collection.season} {collection.release_year} · {collection.status}

          Lead designer
          {collection.lead_designer}
          Piece count
          {collection.piece_count ?? "Unavailable"}

          {collection.description || "No description is available."}

          Edit collection
          {error}
          ; +} diff --git a/react-ui/src/pages/CollectionForm.jsx b/react-ui/src/pages/CollectionForm.jsx new file mode 100644 index 0000000..fcf79c2 --- /dev/null +++ b/react-ui/src/pages/CollectionForm.jsx @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { apiRequest } from "../api"; +import StatusMessage from "../components/StatusMessage"; + +const emptyCollection = { label: "", name: "", season: "", release_year: "", status: "concept", piece_count: "", description: "" }; +export default function CollectionForm() { + const params = useParams(); const editing = Boolean(params.collectionId); const navigate = useNavigate(); + const [designerId, setDesignerId] = useState(params.designerId || ""); const [form, setForm] = useState(emptyCollection); const [status, setStatus] = useState(editing ? "Loading collection…" : ""); const [error, setError] = useState(""); + useEffect(() => { if (!editing) return; apiRequest(`/collections/${params.collectionId}`).then((collection) => { setDesignerId(String(collection.designer_id)); setForm({ label: collection.label, name: collection.name || "", season: collection.season, release_year: collection.release_year, status: collection.status, piece_count: collection.piece_count ?? "", description: collection.description || "" }); setStatus(""); }).catch((error) => { setError(error.message); setStatus(""); }); }, [editing, params.collectionId]); + function updateField(event) { setForm({ ...form, [event.target.name]: event.target.value }); } + async function submit(event) { event.preventDefault(); setStatus("Saving collection…"); setError(""); const payload = { designer_id: Number(designerId), label: form.label, name: form.name || null, season: form.season, release_year: Number(form.release_year), status: form.status, piece_count: form.piece_count === "" ? null : Number(form.piece_count), description: form.description || null }; try { const result = await apiRequest(editing ? `/collections/${params.collectionId}` : "/collections", { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) }); navigate(`/collections/${result.id}`); } catch (error) { setError(error.message); setStatus(""); } } + return <>

          Archive editor

          {editing ? "Edit Collection" : "Add a Collection"}

          {status}{error}