diff --git a/Documentation/API.md b/Documentation/API.md index c3f9aa8..1da70e4 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -195,6 +195,23 @@ Requires curator session. Base path: `/api/translations`. | `PUT` | `/api/translations/:entityType/:id` | Upsert fields `{ locale, fields, status }` | | `POST` | `/api/translations/:entityType/:id/publish` | Publish all draft/reviewed rows for locale | +## Influences (curator) + +Requires curator session. Base path: `/api/influences`. See [influence-import.md](influence-import.md). + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/influences` | List edges (`artistId`, `paintingId`, `q`, pagination) | +| `GET` | `/api/influences/graph?artistId=` | Neighborhood nodes/edges for visualization | +| `POST` | `/api/influences` | Create edge `{ paintingId, sourceType, sourceArtistId\|sourcePaintingId\|sourceMovementId, … }` | +| `PATCH` | `/api/influences/:id` | Update notes / remap source | +| `DELETE` | `/api/influences/:id` | Delete edge | +| `POST` | `/api/influences/import/parse` | Parse upload `{ filename, contentBase64, sheet? }` — returns `contentHash` / `payloadHash` / `alreadyImported` | +| `POST` | `/api/influences/import/preview` | Validate `{ rows, mapping, contentHash?, payloadHash? }` | +| `POST` | `/api/influences/import/commit` | Insert `{ proposals, contentHash?, payloadHash?, force? }` — `409` if duplicate unless `force` | + +Public painting detail still exposes read-only `influencedBy` / `influenced` (unchanged). + --- ## `GET /api/search` diff --git a/Documentation/DB_structure.md b/Documentation/DB_structure.md index 344a261..b679691 100644 --- a/Documentation/DB_structure.md +++ b/Documentation/DB_structure.md @@ -213,7 +213,7 @@ Append-only log of curator debug mutations (fix/clear/upload/delete, checkup fla | `ip_address` | VARCHAR(45) | Client IP (respects `TRUST_PROXY`) | | `created_at` | TIMESTAMPTZ | | -**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`. +**Logged `action` values:** `painting.fix_image`, `painting.clear_image`, `painting.upload_image`, `painting.delete`, `painting.checkup_flags`, `artist.fix_portrait`, `artist.clear_portrait`, `artist.upload_portrait`, `artist.checkup_flags`, `translation.upsert`, `translation.publish`, `influence.create`, `influence.update`, `influence.delete`, `influence.import`. Example query in pgAdmin: diff --git a/Documentation/FAC.md b/Documentation/FAC.md index be6d254..1691a67 100644 --- a/Documentation/FAC.md +++ b/Documentation/FAC.md @@ -94,14 +94,14 @@ CURATOR_USERNAME=curator CURATOR_PASSWORD=your-secure-password ``` -Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup. Mutations are logged in `curator_audit_log` (view in pgAdmin). +Then open the gallery → **Curator login** (top-right) → use debug mode / Checkup / Translations / **Influences**. Mutations are logged in `curator_audit_log` (view in pgAdmin). **Roles:** | Role | Access | |------|--------| | Guest (`user`) | Timeline, movement flow, 3D halls, painting detail, bios | -| Curator | Above + debug mode, Checkup, image fix/upload/delete APIs | +| Curator | Above + debug mode, Checkup, Translations, Influences (import/CRUD/graph), image fix/upload/delete APIs | **Audit log (pgAdmin on `gallery_dev` or `gallery_prod`):** diff --git a/Documentation/Plans.md b/Documentation/Plans.md index 9f46ba4..51bfac8 100644 --- a/Documentation/Plans.md +++ b/Documentation/Plans.md @@ -1,10 +1,10 @@ this file contains draft for future releases and features 1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md) -2. tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities +2. ~~tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities~~ — done: curator Influences page (list CRUD + import wizard CSV/JSON/XLSX + neighborhood graph) — [influence-import.md](influence-import.md) 3. tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ? 4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md) -5. curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome +5. ~~curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome~~ — done: table + `logCuratorAction` on fix/clear/upload/delete/checkup flags (and translation upsert/publish); see [DB_structure.md](DB_structure.md#curator_audit_log). (UI to browse logs is still item 3.) 6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search` 7. create guided tours (with text/extra infor, set of entities) diff --git a/Documentation/basics.md b/Documentation/basics.md index 7979f9a..7545671 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -50,6 +50,10 @@ Gallery/ │ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search │ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail │ │ ├── pages/CheckupPage.tsx # Image audit table +│ │ ├── pages/TranslationsPage.tsx # Russian translation review +│ │ ├── pages/InfluencesPage.tsx # Influence links CRUD + import wizard +│ │ ├── i18n/ # react-i18next bootstrap +│ │ └── locales/{en,ru}/ # UI chrome strings │ │ ├── data/historical-events.ts # Timeline event markers (UI) │ │ ├── data/movement-lineage.ts # Curated movement predecessor links (UI) │ │ ├── utils/parquetFloorTexture.ts # Procedural parquet floor @@ -412,7 +416,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens | Role | Who | Can do | |------|-----|--------| | **`user`** | Anonymous visitor (default) | Browse timeline, movement flow, 3D artist/movement halls, painting detail, artist bios, images | -| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, debug API mutations | +| **`curator`** | Named account (`users` table) | Everything above + **Debug mode**, **Checkup**, **Translations**, **Influences**, debug API mutations | Curators sign in via **Curator login** in the site header. Sessions use an HTTP-only cookie (`gallery.sid`). The UI hides debug controls from guests; the server enforces the same rules on debug/checkup API routes (`401` without a valid session). @@ -429,6 +433,8 @@ Curator-only workflow for reviewing and fixing local image files — not part of | **Debug mode** | Home header toggle (curators only) | Persists in `localStorage`; enables debug panel on painting detail and artist bio | | **Show more** | Home header checkbox (curators, when debug on) | Auto-opens the **More** modal on each painting / bio page load | | **Checkup page** | Home header → **Checkup** (curators only) | Full-catalog table: gallery vs detail thumbnails, search, fix, review flags | +| **Translations** | Home header → **Translations** (curators only) | Review/publish Russian `entity_translations` | +| **Influences** | Home header → **Influences** (curators only) | List/CRUD influence edges, CSV/JSON/XLSX import wizard, neighborhood graph — [influence-import.md](influence-import.md) | | **Logout** | Home header (curators) | Ends session; hides debug tools | | **Debug panel** | Painting detail or artist bio (bottom-left, when debug mode on) | Search preview + action buttons (six on painting detail, five on artist bio) | @@ -474,4 +480,6 @@ See [API.md](API.md#authentication) and [data-and-images.md](data-and-images.md# | [harmonize-dev-prod.md](harmonize-dev-prod.md) | Incremental dev ↔ prod merge (catalog DB + images) | | [DB_structure.md](DB_structure.md) | Tables and relationships | | [API.md](API.md) | REST endpoints | +| [influence-import.md](influence-import.md) | Curator Influences tool — import wizard, CRUD, graph | +| [i18n-russian.md](i18n-russian.md) | Russian UI + entity_translations | | [data-and-images.md](data-and-images.md) | Image pipeline and seeding | diff --git a/Documentation/influence-import.md b/Documentation/influence-import.md new file mode 100644 index 0000000..944da4b --- /dev/null +++ b/Documentation/influence-import.md @@ -0,0 +1,79 @@ +# Influence links import & curator tool + +Curator tool to **list / add / delete** influence edges, **visualize** an artist neighborhood, and **import** CSV / JSON / XLSX files through a mapping wizard. + +Entry: header **Influences** (curator session). Data lives in `painting_influence_sources` (legacy `painting_influences` mirrored for painting→painting). + +--- + +## Expansion rule (artist-level files) + +Input workbooks such as [`Inputs/artist_influences_web_sources.xlsx`](../Inputs/artist_influences_web_sources.xlsx) are **artist-centric**. On import: + +- **Influenced by** tokens → attach as sources on **all paintings** of the subject artist +- **Influenced** tokens that resolve to an artist → reverse link: subject artist becomes a source on **all paintings** of the influenced artist (same as PainterPalette `Influencedon`) + +Unresolved names (artists / movements / paintings not in the DB) are **skipped** with warnings — no auto-create stubs. + +--- + +## Wizard column roles + +| Role | Meaning | +|------|---------| +| `subject_artist` | Artist the row is about (required) | +| `subject_painting` | Optional work hint (contextual; expansion still uses all works) | +| `influenced_by` | Who/what influenced the subject (`;` / `,` separated) | +| `influenced` | Who the subject influenced | +| `notes` / `reference` / `source_url` | Citation metadata (URLs scraped from reference text) | +| `ignore` | Skip column | + +### Presets + +| Preset | Typical headers | +|--------|-----------------| +| Web sources | `Artist`, `Painting`, `Influenced by`, `Influenced`, `Reference (source + link)` | +| Story of Art | Same Title Case (+ chapter reference column) | +| Art influences | `artist`, `painting`, `influenced_by`, `influenced`, `reference` | +| Custom | Map any columns manually | + +Token classification order: **artist → movement → painting title** (under subject artist, then global). + +Committed edges use `confidence=curated`, `discovered_via=import-wizard`. + +### Duplicate file / data guard + +Each successful commit stores SHA-256 fingerprints in `curator_audit_log` (`influence.import` details): + +- `contentHash` — raw file bytes +- `payloadHash` — normalized mapped rows (same data under another filename still matches) + +On parse/preview, if either hash matches a prior import, the UI warns and **blocks commit** unless the curator checks **Import anyway (force)**. Individual edges remain unique via DB `ON CONFLICT` either way. + +--- + +## API (curator) + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/influences` | List (`artistId`, `paintingId`, `q`, `limit`, `offset`) | +| `GET` | `/api/influences/graph?artistId=` | Nodes + edges for SVG neighborhood | +| `POST` | `/api/influences` | Create one edge | +| `PATCH` | `/api/influences/:id` | Update metadata / remap source | +| `DELETE` | `/api/influences/:id` | Delete (+ legacy mirror) | +| `POST` | `/api/influences/import/parse` | `{ filename, contentBase64, sheet? }` → columns, hashes, `alreadyImported` | +| `POST` | `/api/influences/import/preview` | `{ rows, mapping, contentHash?, payloadHash? }` → proposals + duplicate check | +| `POST` | `/api/influences/import/commit` | `{ proposals, fileName?, contentHash?, payloadHash?, force? }` — `409 ALREADY_IMPORTED` unless `force` | + +Audit: `influence.create` / `update` / `delete` / `import` in `curator_audit_log`. + +--- + +## CLI still available + +- `npm run dev:update-influences` — curated [`scripts/art-influences-data.js`](../scripts/art-influences-data.js) +- `npm run dev:import-painter-palette` — PainterPalette CSV + +The wizard is the interactive path for ad-hoc spreadsheets under `Inputs/`. + +See also [API.md](API.md), [DB_structure.md](DB_structure.md), [Plans.md](Plans.md). diff --git a/Documentation/setup.md b/Documentation/setup.md index 6afe481..e4fe44a 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.md @@ -72,7 +72,7 @@ If migration fails with permission errors, grant schema rights to the app user f `npm run dev:migrate` applies `db/migrate-auth.sql` (`users`, `curator_audit_log`, `session` tables). When the `users` table is empty and `CURATOR_USERNAME` / `CURATOR_PASSWORD` are set in `.env`, the first curator account is created automatically. -After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline and 3D halls without logging in. +After migrate, sign in from the site header (**Curator login**). Debug mode, Checkup, Translations, Influences, and all mutating debug APIs require an active curator session. Anonymous visitors browse the timeline and 3D halls without logging in. See [API.md — Authentication](API.md#authentication) and [basics.md — Developer tools](basics.md#developer-tools-image-audit). @@ -120,7 +120,7 @@ Image fetch can take hours if you run it for the entire catalog. The first line | `npm run dev:server` | API with nodemon reload (local HMR workflow) | | `npm run dev:client` | Vite dev server on :5173 | -See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For Russian UI + catalog text, see [i18n-russian.md](i18n-russian.md). Quick reference: [FAC.md](FAC.md). +See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For Russian UI + catalog text, see [i18n-russian.md](i18n-russian.md). Influence link import/CRUD: [influence-import.md](influence-import.md). Quick reference: [FAC.md](FAC.md). **Production frontend:** build the client, then start the server: @@ -274,6 +274,9 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time | Catalog search returns empty / 500 | Search indexes missing | Run `npm run dev:migrate` (includes `migrate-search.sql`) or `npm run dev:migrate:search`; restart API | | `fetch-artist-bios-ru` fails: `index row size … exceeds btree maximum` | Old `entity_translations` index on all `value` text | `npm run dev:migrate` (partial search index on name/title only), then re-run `npm run dev:fetch-artist-bios-ru` | | Russian UI shows English catalog names | Translations not published | Curator → Translations → Publish; public API serves only `status = published` | +| Influence import leaves many unresolved tokens | Names not in catalog DB | Curator → Influences → Import warnings; fix spelling or add artists first; free-text traditions stay unresolved | +| Influence import created too many edges | Artist-level rows expand to all paintings | Expected (PainterPalette-style); delete unwanted edges in Influences list | +| Influence import blocked: already imported | Same file bytes or mapped data imported before | Expected; use **Import anyway** only if intentional, or skip | | Frame still black after **Checked** | Gallery session not synced | Re-enter hall or toggle debug **Checked** from detail with gallery open behind overlay | | Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run dev:find-duplicates`; merge or delete spare rows manually | | **Failed to load movement gallery** / `Cannot GET /api/movements/:id/gallery` | Stale server process missing route | Restart `npm run dev:web` or `npm run dev:server` after pulling API changes | diff --git a/client/src/api/client.ts b/client/src/api/client.ts index b98d718..9121ba6 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -188,6 +188,23 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim }); } +async function fileToBase64Raw(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result; + if (typeof result !== 'string') { + reject(new Error('Could not read file')); + return; + } + const comma = result.indexOf(','); + resolve(comma >= 0 ? result.slice(comma + 1) : result); + }; + reader.onerror = () => reject(new Error('Could not read file')); + reader.readAsDataURL(file); + }); +} + function mimeTypeFromFilename(filename: string): string | null { const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1]; switch (ext) { @@ -516,6 +533,159 @@ export const api = { return res.json(); }), + listInfluences: (params: { + artistId?: number; + paintingId?: number; + q?: string; + limit?: number; + offset?: number; + } = {}) => { + const qs = new URLSearchParams(); + if (params.artistId) qs.set('artistId', String(params.artistId)); + if (params.paintingId) qs.set('paintingId', String(params.paintingId)); + if (params.q) qs.set('q', params.q); + if (params.limit) qs.set('limit', String(params.limit)); + if (params.offset) qs.set('offset', String(params.offset)); + const q = qs.toString(); + return fetchJson<{ items: InfluenceEdgeItem[]; total: number; limit: number; offset: number }>( + `${API}/influences${q ? `?${q}` : ''}`, + ); + }, + + getInfluenceGraph: (params: { artistId?: number; paintingId?: number }) => { + const qs = new URLSearchParams(); + if (params.artistId) qs.set('artistId', String(params.artistId)); + if (params.paintingId) qs.set('paintingId', String(params.paintingId)); + return fetchJson(`${API}/influences/graph?${qs.toString()}`); + }, + + createInfluence: (payload: { + paintingId: number; + sourceType: 'painting' | 'artist' | 'movement'; + sourcePaintingId?: number; + sourceArtistId?: number; + sourceMovementId?: number; + notes?: string; + source?: string; + sourceUrl?: string; + }) => + fetch(`${API}/influences`, { + ...fetchCredentials, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Create failed: ${res.status}`); + } + return res.json() as Promise<{ id: number }>; + }), + + updateInfluence: ( + id: number, + payload: Partial<{ + notes: string; + source: string; + sourceUrl: string; + confidence: string; + sourceType: 'painting' | 'artist' | 'movement'; + sourcePaintingId: number; + sourceArtistId: number; + sourceMovementId: number; + }>, + ) => + fetch(`${API}/influences/${id}`, { + ...fetchCredentials, + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Update failed: ${res.status}`); + } + return res.json() as Promise<{ id: number }>; + }), + + deleteInfluence: (id: number) => + fetch(`${API}/influences/${id}`, { + ...fetchCredentials, + method: 'DELETE', + }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Delete failed: ${res.status}`); + } + return res.json() as Promise<{ ok: boolean }>; + }), + + parseInfluenceImport: async (file: File, sheet?: string) => { + const contentBase64 = await fileToBase64Raw(file); + return fetch(`${API}/influences/import/parse`, { + ...fetchCredentials, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename: file.name, + sheet, + contentBase64, + }), + }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Parse failed: ${res.status}`); + } + return res.json() as Promise; + }); + }, + + previewInfluenceImport: (payload: { + rows: Record[]; + mapping: Record; + sourceLabel?: string; + contentHash?: string; + payloadHash?: string; + }) => + fetch(`${API}/influences/import/preview`, { + ...fetchCredentials, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || `Preview failed: ${res.status}`); + } + return res.json() as Promise; + }), + + commitInfluenceImport: (payload: { + proposals: InfluenceImportProposal[]; + fileName?: string; + contentHash?: string; + payloadHash?: string; + force?: boolean; + }) => + fetch(`${API}/influences/import/commit`, { + ...fetchCredentials, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }).then(async (res) => { + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const err = new Error(body.error || `Commit failed: ${res.status}`) as Error & { + code?: string; + priorImport?: InfluencePriorImport; + }; + err.code = body.code; + err.priorImport = body.priorImport; + throw err; + } + return res.json() as Promise<{ inserted: number; skipped: number; attempted: number }>; + }), + preloadArtistImages, }; @@ -542,6 +712,108 @@ export interface TranslationDetail { }>; } +export interface InfluenceEdgeItem { + id: number; + paintingId: number; + paintingTitle: string; + paintingYear: number | null; + artistId: number; + artistName: string; + sourceType: 'painting' | 'artist' | 'movement'; + sourcePaintingId: number | null; + sourceArtistId: number | null; + sourceMovementId: number | null; + sourceLabel: string | null; + notes: string | null; + source: string | null; + sourceUrl: string | null; + aspects: string | null; + quote: string | null; + confidence: string | null; + discoveredVia: string | null; + updatedAt: string | null; +} + +export interface InfluenceGraph { + focus: { artistId: number; paintingId: number | null; label: string }; + nodes: Array<{ id: string; type: string; label: string; focus?: boolean; artistId?: number; paintingId?: number; movementId?: number }>; + edges: Array<{ id: number; from: string; to: string; direction: string; label: string }>; +} + +export interface InfluencePriorImport { + importedAt: string; + username: string | null; + fileName: string | null; + inserted: number | null; + contentHash: string | null; + payloadHash: string | null; + match: 'file' | 'data' | 'unknown'; +} + +export interface InfluenceImportParseResult { + filename: string; + format: string; + sheets: string[] | null; + sheet: string | null; + columns: string[]; + rowCount: number; + sampleRows: Record[]; + rows?: Record[]; + suggestedPreset: string; + suggestedMapping: Record; + roles: string[]; + presets: Array<{ id: string; label: string; mapping: Record }>; + contentHash?: string; + payloadHash?: string; + alreadyImported?: boolean; + priorImport?: InfluencePriorImport | null; +} + +export interface InfluenceImportProposal { + rowIndex: number; + direction: string; + paintingId: number; + paintingTitle: string; + artistId: number; + artistName: string; + sourceType: string; + sourcePaintingId: number | null; + sourceArtistId: number | null; + sourceMovementId: number | null; + sourceLabel: string; + token: string; + notes: string | null; + source: string | null; + sourceUrl: string | null; + confidence: string; + discoveredVia: string; + edgeKey: string; + action: 'create' | 'skip'; + reason: string | null; +} + +export interface InfluenceImportPreview { + proposals: InfluenceImportProposal[]; + warnings: Array<{ + rowIndex: number; + message: string; + token?: string; + direction?: string; + candidates?: Array<{ sourceType: string; sourcePaintingId?: number; label: string }>; + }>; + counts: { + rows: number; + proposals: number; + willCreate: number; + willSkip: number; + errors: number; + }; + contentHash?: string | null; + payloadHash?: string | null; + alreadyImported?: boolean; + priorImport?: InfluencePriorImport | null; +} + export function debugImageProxyUrl( imageUrl: string, context?: { searchUrl?: string; source?: string } diff --git a/client/src/i18n/index.ts b/client/src/i18n/index.ts index b57a1c9..4eb3604 100644 --- a/client/src/i18n/index.ts +++ b/client/src/i18n/index.ts @@ -11,6 +11,7 @@ import enBio from '../locales/en/bio.json'; import enAnnotations from '../locales/en/annotations.json'; import enDebug from '../locales/en/debug.json'; import enTranslations from '../locales/en/translations.json'; +import enInfluences from '../locales/en/influences.json'; import ruCommon from '../locales/ru/common.json'; import ruHome from '../locales/ru/home.json'; @@ -21,6 +22,7 @@ import ruBio from '../locales/ru/bio.json'; import ruAnnotations from '../locales/ru/annotations.json'; import ruDebug from '../locales/ru/debug.json'; import ruTranslations from '../locales/ru/translations.json'; +import ruInfluences from '../locales/ru/influences.json'; const initialLocale = readStoredLocale(); writeStoredLocale(initialLocale); @@ -29,7 +31,7 @@ void i18n.use(initReactI18next).init({ lng: initialLocale, fallbackLng: 'en', supportedLngs: ['en', 'ru'], - ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations'], + ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences'], defaultNS: 'common', resources: { en: { @@ -42,6 +44,7 @@ void i18n.use(initReactI18next).init({ annotations: enAnnotations, debug: enDebug, translations: enTranslations, + influences: enInfluences, }, ru: { common: ruCommon, @@ -53,6 +56,7 @@ void i18n.use(initReactI18next).init({ annotations: ruAnnotations, debug: ruDebug, translations: ruTranslations, + influences: ruInfluences, }, }, interpolation: { escapeValue: false }, diff --git a/client/src/locales/en/home.json b/client/src/locales/en/home.json index 38f1252..8c71ca4 100644 --- a/client/src/locales/en/home.json +++ b/client/src/locales/en/home.json @@ -14,6 +14,7 @@ "showMoreDebug": "Show more (debug)", "checkup": "Checkup", "translations": "Translations", + "influences": "Influences", "curatorRequiredTitle": "Curator access required", "curatorRequiredBody": "Sign in as a curator to use this tool.", "backToGalleryBtn": "Back to gallery" diff --git a/client/src/locales/en/influences.json b/client/src/locales/en/influences.json new file mode 100644 index 0000000..04478ea --- /dev/null +++ b/client/src/locales/en/influences.json @@ -0,0 +1,70 @@ +{ + "title": "Influence links", + "back": "← Back to gallery", + "tabList": "List", + "tabImport": "Import", + "tabGraph": "Graph", + "loadFailed": "Failed to load influences", + "searchPlaceholder": "Search artist, painting, source…", + "refresh": "Refresh", + "loading": "Loading…", + "loadingParse": "Reading file…", + "loadingPreview": "Validating influence links…", + "loadingCommit": "Importing links into the database…", + "loadingSave": "Saving link…", + "addEdge": "Add link", + "cancelAdd": "Cancel", + "total": "{{count}} links", + "filtered": "filtered by artist", + "clearFilter": "Clear artist filter", + "subjectPainting": "Subject painting", + "searchPainting": "Search painting…", + "sourceType": "Source type", + "typeArtist": "Artist", + "typePainting": "Painting", + "typeMovement": "Movement", + "sourceEntity": "Source entity", + "searchSource": "Search source…", + "notes": "Notes", + "saveEdge": "Save link", + "addRequiresIds": "Pick a subject painting and a source entity", + "colSubject": "Subject", + "colSource": "Source", + "colType": "Type", + "colNotes": "Notes", + "colActions": "Actions", + "colAction": "Action", + "colDirection": "Direction", + "delete": "Delete", + "confirmDelete": "Delete this influence link?", + "noEdges": "No influence links match.", + "stepUpload": "1. Upload", + "stepMapping": "2. Map columns", + "stepPreview": "3. Validate", + "stepDone": "4. Done", + "uploadHelp": "Choose a CSV, JSON, or XLSX file with influence rows (e.g. Inputs/artist_influences_web_sources.xlsx).", + "parseFailed": "Failed to parse file", + "previewFailed": "Failed to build preview", + "commitFailed": "Failed to commit import", + "rowsMissing": "File rows were not returned (too large). Use a smaller file (≤2000 rows).", + "fileInfo": "{{name}} · {{rows}} rows · {{format}}", + "sheet": "Sheet", + "preset": "Mapping preset", + "column": "Column", + "role": "Role", + "sample": "Sample", + "backStep": "Back", + "runPreview": "Validate & preview", + "previewCounts": "Will create {{create}} · skip {{skip}} · row errors {{errors}} · proposals {{proposals}}", + "warnings": "{{count}} warnings", + "commitImport": "Import {{count}} links", + "commitSummary": "Imported {{inserted}} links ({{skipped}} already present or skipped).", + "alreadyImportedWarn": "Already imported {{when}} by {{who}} ({{match}}: {{file}}). Commit is blocked unless you force re-import.", + "alreadyImportedBlock": "This file or identical data was already imported. Enable “Import anyway” to force, or cancel.", + "forceImport": "Import anyway (force — may recreate skipped edges only; existing edges stay unique)", + "matchFile": "same file bytes", + "matchData": "same mapped data", + "searchArtist": "Search artist for neighborhood graph…", + "graphEmpty": "Select an artist to visualize influence neighbors.", + "graphStats": "{{nodes}} nodes · {{edges}} edges" +} diff --git a/client/src/locales/ru/home.json b/client/src/locales/ru/home.json index 8fc9b55..50e2d25 100644 --- a/client/src/locales/ru/home.json +++ b/client/src/locales/ru/home.json @@ -14,6 +14,7 @@ "showMoreDebug": "Показать больше (отладка)", "checkup": "Проверка", "translations": "Переводы", + "influences": "Влияния", "curatorRequiredTitle": "Требуется доступ куратора", "curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.", "backToGalleryBtn": "Вернуться в галерею" diff --git a/client/src/locales/ru/influences.json b/client/src/locales/ru/influences.json new file mode 100644 index 0000000..5ef8081 --- /dev/null +++ b/client/src/locales/ru/influences.json @@ -0,0 +1,70 @@ +{ + "title": "Связи влияния", + "back": "← Назад в галерею", + "tabList": "Список", + "tabImport": "Импорт", + "tabGraph": "Граф", + "loadFailed": "Не удалось загрузить связи", + "searchPlaceholder": "Поиск: художник, картина, источник…", + "refresh": "Обновить", + "loading": "Загрузка…", + "loadingParse": "Чтение файла…", + "loadingPreview": "Проверка связей влияния…", + "loadingCommit": "Импорт связей в базу…", + "loadingSave": "Сохранение связи…", + "addEdge": "Добавить связь", + "cancelAdd": "Отмена", + "total": "{{count}} связей", + "filtered": "фильтр по художнику", + "clearFilter": "Сбросить фильтр", + "subjectPainting": "Картина (субъект)", + "searchPainting": "Поиск картины…", + "sourceType": "Тип источника", + "typeArtist": "Художник", + "typePainting": "Картина", + "typeMovement": "Направление", + "sourceEntity": "Источник", + "searchSource": "Поиск источника…", + "notes": "Заметки", + "saveEdge": "Сохранить связь", + "addRequiresIds": "Выберите картину и источник", + "colSubject": "Субъект", + "colSource": "Источник", + "colType": "Тип", + "colNotes": "Заметки", + "colActions": "Действия", + "colAction": "Действие", + "colDirection": "Направление", + "delete": "Удалить", + "confirmDelete": "Удалить эту связь влияния?", + "noEdges": "Связи не найдены.", + "stepUpload": "1. Файл", + "stepMapping": "2. Столбцы", + "stepPreview": "3. Проверка", + "stepDone": "4. Готово", + "uploadHelp": "Выберите CSV, JSON или XLSX со связями влияния (например Inputs/artist_influences_web_sources.xlsx).", + "parseFailed": "Не удалось разобрать файл", + "previewFailed": "Не удалось построить превью", + "commitFailed": "Не удалось выполнить импорт", + "rowsMissing": "Строки файла не получены (слишком большой файл). Используйте ≤2000 строк.", + "fileInfo": "{{name}} · {{rows}} строк · {{format}}", + "sheet": "Лист", + "preset": "Шаблон сопоставления", + "column": "Столбец", + "role": "Роль", + "sample": "Пример", + "backStep": "Назад", + "runPreview": "Проверить", + "previewCounts": "Создать {{create}} · пропустить {{skip}} · ошибки строк {{errors}} · предложений {{proposals}}", + "warnings": "{{count}} предупреждений", + "commitImport": "Импортировать {{count}} связей", + "commitSummary": "Импортировано {{inserted}} (пропущено {{skipped}}).", + "alreadyImportedWarn": "Уже импортировано {{when}} пользователем {{who}} ({{match}}: {{file}}). Импорт заблокирован, пока не включите принудительный повтор.", + "alreadyImportedBlock": "Этот файл или те же данные уже импортировались. Включите «Импортировать всё равно» или отмените.", + "forceImport": "Импортировать всё равно (принудительно — существующие связи остаются уникальными)", + "matchFile": "те же байты файла", + "matchData": "те же сопоставленные данные", + "searchArtist": "Поиск художника для графа…", + "graphEmpty": "Выберите художника, чтобы увидеть соседей по влиянию.", + "graphStats": "{{nodes}} узлов · {{edges}} рёбер" +} diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index aadd029..8b295f7 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -8,6 +8,7 @@ import PaintingDetailView from '../components/PaintingDetail'; import ArtistBio from '../components/ArtistBio'; import CheckupPage from '../pages/CheckupPage'; import TranslationsPage from '../pages/TranslationsPage'; +import InfluencesPage from '../pages/InfluencesPage'; import CuratorLoginModal from '../components/CuratorLoginModal'; import CatalogSearchBar from '../components/CatalogSearchBar'; import LocaleSwitcher from '../components/LocaleSwitcher'; @@ -16,6 +17,7 @@ import '../components/CatalogSearchBar.css'; import '../components/CuratorLoginModal.css'; import '../components/LocaleSwitcher.css'; import '../pages/TranslationsPage.css'; +import '../pages/InfluencesPage.css'; import { useAuth } from '../context/AuthContext'; import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client'; import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types'; @@ -28,6 +30,7 @@ type View = | { type: 'timeline' } | { type: 'checkup' } | { type: 'translations' } + | { type: 'influences' } | { type: 'gallery'; artistId: number; data: ArtistDetail } | { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail } | { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View } @@ -128,7 +131,7 @@ export default function HomePage() { const [debugMode, setDebugMode] = useState(readDebugMode); const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore); const [loginOpen, setLoginOpen] = useState(false); - const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | null>(null); + const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | 'influences' | null>(null); const effectiveDebugMode = debugMode && isCurator; const [galleryRevision, setGalleryRevision] = useState(0); const viewRef = useRef(view); @@ -222,7 +225,7 @@ export default function HomePage() { writeDebugShowMore(enabled); }; - const openCuratorLogin = (redirect: 'checkup' | 'translations' | null = null) => { + const openCuratorLogin = (redirect: 'checkup' | 'translations' | 'influences' | null = null) => { setLoginRedirect(redirect); setLoginOpen(true); }; @@ -234,6 +237,8 @@ export default function HomePage() { setView({ type: 'checkup' }); } else if (loginRedirect === 'translations') { setView({ type: 'translations' }); + } else if (loginRedirect === 'influences') { + setView({ type: 'influences' }); } setLoginRedirect(null); }; @@ -242,7 +247,7 @@ export default function HomePage() { await logout(); writeDebugMode(false); setDebugMode(false); - if (view.type === 'checkup' || view.type === 'translations') { + if (view.type === 'checkup' || view.type === 'translations' || view.type === 'influences') { goToTimelineHome(); } }; @@ -263,6 +268,14 @@ export default function HomePage() { setView({ type: 'translations' }); }; + const openInfluences = () => { + if (!isCurator) { + openCuratorLogin('influences'); + return; + } + setView({ type: 'influences' }); + }; + const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => { const data = await api.getPainting(paintingId); const patch: Partial = { @@ -746,6 +759,25 @@ export default function HomePage() { )} + {view.type === 'influences' && ( + isCurator ? ( + + ) : ( +
+

{t('curatorRequiredTitle')}

+

{t('curatorRequiredBody')}

+
+ + +
+
+ ) + )} + {view.type === 'translations' && ( isCurator ? ( @@ -824,6 +856,14 @@ export default function HomePage() { /> Show more + +

{t('title')}

+ + + + {error &&
{error}
} + + {tab === 'list' && ( +
+
+ setQ(e.target.value)} + placeholder={t('searchPlaceholder')} + /> + + + + {t('total', { count: total })} + {filterArtistId ? ` · ${t('filtered')}` : ''} + + {filterArtistId && ( + + )} +
+ + {showAdd && ( +
+ + {searchHits.length > 0 && !addPaintingId && ( +
    + {searchHits.map((h) => ( +
  • + +
  • + ))} +
+ )} + + + {sourceHits.length > 0 && !addSourceId && ( +
    + {sourceHits.map((h) => ( +
  • + +
  • + ))} +
+ )} +