From d2a20fb4ea393297d847e010f898a03fc9b19eb6 Mon Sep 17 00:00:00 2001 From: nikazzio Date: Thu, 1 Oct 2026 08:59:36 +0200 Subject: [PATCH 01/32] feat(transcriptions): catalogue on the Library model, studio fixes, area inks - Studio: the root can shrink below its toolbars' natural width, so the right column no longer leaves the window; manual save of a version (command next to the save indicator and Ctrl/Cmd+S, also inside the page). - Transcriptions landing page rebuilt as a catalogue like the Library: shelves, search, quick filters (workspace, library, century), sort, grouping, list/grid/table views, row commands (open work, rename | archive, delete). New listTranscriptionCatalog query with written and verified pages. - Shared catalogue parts extracted from the Library and reused by both: CommandBar, ShelfItem, CatalogSearchField, CatalogViewSwitch, CompletionBar, catalogStyles, catalogGrouping. - Area inks and papers (Library petrol, Transcriptions sepia, Translations indigo): rule under the area title, tinted rail icon, background paper, sepia placeholder for missing covers. - Docs: in-app guide, public guides (IT/EN), architecture, design system, roadmap. --- docs-dev/ARCHITECTURE.md | 26 +- docs-dev/ROADMAP_2_0.md | 8 + docs-dev/UI_DESIGN_SYSTEM.md | 35 +- docs/en/guides/keyboard-shortcuts.md | 1 + docs/en/guides/projects-and-workspace.md | 10 + docs/en/guides/transcription.md | 40 +- docs/guides/keyboard-shortcuts.md | 1 + docs/guides/projects-and-workspace.md | 10 + docs/guides/transcription.md | 41 +- .../layout/shell-next/WorkspaceRailNext.tsx | 10 +- .../transcription/TranscriptionCatalogRow.tsx | 166 ++++++++ .../TranscriptionCatalogTable.tsx | 94 +++++ .../TranscriptionQuickFilters.tsx | 96 +++++ .../transcription/TranscriptionShelves.tsx | 43 +++ .../transcription/TranscriptionStudio.tsx | 31 +- src/components/ui/AreaHeading.tsx | 42 ++ src/components/ui/CatalogSearchField.tsx | 24 ++ src/components/ui/CatalogViewSwitch.tsx | 26 ++ src/components/ui/CommandBar.tsx | 113 ++++++ src/components/ui/CompletionBar.tsx | 25 ++ src/components/ui/ShelfItem.tsx | 61 +++ src/components/ui/catalogStyles.ts | 29 ++ src/components/ui/index.ts | 6 + .../workspace/LibraryCatalogArea.tsx | 37 +- .../workspace/LibraryCatalogRow.tsx | 16 +- .../workspace/LibraryCatalogTable.tsx | 3 +- .../workspace/LibraryQuickFilters.tsx | 29 +- src/components/workspace/LibraryShelves.tsx | 60 +-- src/components/workspace/SourceActionBar.tsx | 93 +---- .../TranscriptionsCatalogArea.test.tsx | 108 ++++++ .../workspace/TranscriptionsCatalogArea.tsx | 365 ++++++++++++------ src/components/workspace/TranslationsArea.tsx | 55 ++- src/i18n/en.json | 93 ++++- src/i18n/it.json | 95 ++++- src/index.css | 17 + .../transcriptionCatalogService.test.ts | 71 ++++ src/services/transcriptionCatalogService.ts | 77 ++++ src/services/transcriptionService.ts | 13 + src/stores/uiStore.ts | 20 +- src/utils/catalogGrouping.ts | 24 ++ src/utils/libraryCatalogFilters.ts | 4 +- src/utils/libraryGrouping.ts | 21 +- src/utils/transcriptionCatalogFilters.test.ts | 156 ++++++++ src/utils/transcriptionCatalogFilters.ts | 154 ++++++++ 44 files changed, 2051 insertions(+), 398 deletions(-) create mode 100644 src/components/transcription/TranscriptionCatalogRow.tsx create mode 100644 src/components/transcription/TranscriptionCatalogTable.tsx create mode 100644 src/components/transcription/TranscriptionQuickFilters.tsx create mode 100644 src/components/transcription/TranscriptionShelves.tsx create mode 100644 src/components/ui/AreaHeading.tsx create mode 100644 src/components/ui/CatalogSearchField.tsx create mode 100644 src/components/ui/CatalogViewSwitch.tsx create mode 100644 src/components/ui/CommandBar.tsx create mode 100644 src/components/ui/CompletionBar.tsx create mode 100644 src/components/ui/ShelfItem.tsx create mode 100644 src/components/ui/catalogStyles.ts create mode 100644 src/components/workspace/TranscriptionsCatalogArea.test.tsx create mode 100644 src/services/__tests__/transcriptionCatalogService.test.ts create mode 100644 src/services/transcriptionCatalogService.ts create mode 100644 src/utils/catalogGrouping.ts create mode 100644 src/utils/transcriptionCatalogFilters.test.ts create mode 100644 src/utils/transcriptionCatalogFilters.ts diff --git a/docs-dev/ARCHITECTURE.md b/docs-dev/ARCHITECTURE.md index f9173b8c..394f02bb 100644 --- a/docs-dev/ARCHITECTURE.md +++ b/docs-dev/ARCHITECTURE.md @@ -381,8 +381,8 @@ approvate; traduzioni attive la cui origine è una copia dell'opera o una sua trascrizione. Vista (`libraryView`: elenco, copertine, tabella) e raggruppamento -(`libraryGrouping`, `utils/libraryGrouping.ts`: secolo, autore, biblioteca, -raccolta) sono preferenze persistite in `uiStore`. Il raggruppamento lavora +(`libraryGrouping`, `utils/libraryGrouping.ts` sopra `utils/catalogGrouping.ts`: +secolo, autore, biblioteca, raccolta) sono preferenze persistite in `uiStore`. Il raggruppamento lavora sull'elenco già filtrato e ordinato; un'opera in più raccolte compare in ogni gruppo, e la scelta per intervallo segue l'ordine visibile, gruppi compresi. @@ -486,6 +486,10 @@ deduplicate per impronta del contenuto (`content_hash`), un segmento senza di stato propria. Il testo si salva dopo 30 secondi senza modifiche, e subito lasciando la pagina. +Il salvataggio manuale (comando accanto all'indicatore, Ctrl/⌘+S ascoltato sulla +finestra mentre lo Studio è montato, anche dentro il foglio) passa dalla stessa +coda di `save` e scrive una revisione senza nome; è spento quando il testo è +già salvato o il foglio è in sola lettura. Ogni caricamento è legato all'indice di pagina che lo ha richiesto: una risposta tardiva non può sostituire testo e storico della pagina ora aperta. Le versioni consolidate sono le stesse revisioni con `consolidated_name` valorizzato: nessuna @@ -520,6 +524,24 @@ digitalizzazione) resta su un solo blocco di testo, in posizione 0, `source_page_id` sempre `NULL` — lo stesso codice, solo che la pagina non cambia mai. +**Catalogo** (`TranscriptionsCatalogArea`): stesso modello della Biblioteca e +stessi pezzi (`ShelfItem`, `CatalogSearchField`, `CatalogViewSwitch`, +`CompletionBar`, `CommandBar`, `ui/catalogStyles.ts`, +`utils/catalogGrouping.ts`). `listTranscriptionCatalog` +(`services/transcriptionCatalogService.ts`) legge in una query tutte le +trascrizioni attive e archiviate di tutti i workspace, con pagine scritte +(ultima revisione con testo non vuoto), pagine verificate e ultima revisione; +l'opera arriva da `listLibraryCatalog` tramite `source_versions.source_id`, +quindi con le correzioni manuali e il totale di pagine della Biblioteca. +Scaffali, filtri rapidi (workspace, biblioteca, secolo), ordine e +raggruppamento vivono in `utils/transcriptionCatalogFilters.ts`; vista e +raggruppamento sono preferenze persistite (`transcriptionsView`, +`transcriptionsGrouping` in `uiStore`), il filtro workspace segue +l'indirizzo come in Biblioteca. «Verificata» vuol dire tutte le pagine +dell'opera verificate, o tutte quelle scritte se il totale non si conosce. +Rinomina (`renameDocument`) e archiviazione (`setDocumentStatus` con +`archived`) sono comandi di riga; il cestino resta `trashed`. + **Studio di trascrizione** (`TranscriptionsCatalogArea` + `TranscriptionStudio`, #388): stessa convenzione della scheda opera in Biblioteca, non quella dello Studio di traduzione — `AppLocation` porta `{ area: 'transcriptions', diff --git a/docs-dev/ROADMAP_2_0.md b/docs-dev/ROADMAP_2_0.md index 519c403d..526e0b4b 100644 --- a/docs-dev/ROADMAP_2_0.md +++ b/docs-dev/ROADMAP_2_0.md @@ -221,6 +221,14 @@ e il testo delle pagine vicine come contesto di continuità nel prompt. L'immagine inviata (ottimizzata a misura scelta o copia locale così com'è) si sceglie in Impostazioni → Trascrizioni e per sessione nella scheda OCR. +**Stato al 1° ottobre 2026 (#485 H e L).** Lo Studio sta nella finestra a +ogni larghezza; salvataggio manuale di una versione (comando e Ctrl+S). La +pagina iniziale delle Trascrizioni è un catalogo sul modello della Biblioteca, +con gli stessi pezzi condivisi (scaffali, ricerca, filtri rapidi, tre viste, +comandi di riga, barretta di completamento). **Restano**: portare lo stesso +modello alla pagina iniziale delle Traduzioni; scelta multipla nel catalogo +delle Trascrizioni, non chiesta per ora. + Uscita: aprire una fonte reale, trascrivere più pagine — a mano o assistite da OCR/HTR —, correggere, riaprire e ritrovare testo, revisioni e riferimenti alla fonte. diff --git a/docs-dev/UI_DESIGN_SYSTEM.md b/docs-dev/UI_DESIGN_SYSTEM.md index 87e260c8..3e5b4bb1 100644 --- a/docs-dev/UI_DESIGN_SYSTEM.md +++ b/docs-dev/UI_DESIGN_SYSTEM.md @@ -42,6 +42,16 @@ Usare ruoli semantici per le superfici: | `bg-surface-panel` | sidebar, colonne e pannelli | | `bg-surface-hover/50` | hover su righe cliccabili | +Ogni area con un catalogo ha un **inchiostro** e una **carta** propri +(`area-library`, `area-transcriptions` seppia, `area-translations` indaco, e le +rispettive `-paper`), con le classi in `AREA_INK_CLASSNAME` e +`AREA_PAPER_CLASSNAME`. L'inchiostro va solo su segni piccoli: il filetto sotto +il titolo (`AreaHeading`), l'icona dell'area a riposo nella barra di sinistra, +il segnaposto di una copertina mancante. La carta è il fondo dell'elenco e dei +titoletti di gruppo fermi in cima; la colonna degli scaffali resta +`surface-panel`. Mai su comandi, selezione o stati: lì restano verde, rosso, +ocra e oro. Contrasto sul fondo ≥ 7:1 in entrambi i temi. + Campi, select e textarea usano `editorial-textbox` pieno. Vietati colori Tailwind grezzi e valori esadecimali nei componenti. @@ -249,7 +259,8 @@ interattiva. ### Intestazione di un'area Ogni area globale apre con il **titolo grande** in `font-display` corsivo -(`text-4xl md:text-5xl`): Traduzioni, Trascrizioni, Analisi, Biblioteca. I +(`text-4xl md:text-5xl`): Traduzioni, Trascrizioni, Analisi, Biblioteca; le tre +aree con un inchiostro usano `AreaHeading`, che aggiunge il filetto. I comandi propri dell'elenco (vista, ordinamento) stanno in fondo alla stessa riga, allineati alla base del titolo. La Biblioteca usava una `SectionLabel` piccola con icona: era l'unica area a non somigliare alle altre. @@ -332,6 +343,28 @@ non in maiuscoletto spaziato: il maiuscolo su elenchi lunghi si legge peggio e rallenta. Vale per i dati che arrivano da fuori (le voci di un manifesto), dove i nomi li sceglie la biblioteca e possono essere lunghi. +### Catalogo: un solo modello + +Biblioteca e Trascrizioni sono lo stesso catalogo, e le Traduzioni lo +seguiranno: titolo grande con `CatalogViewSwitch` (elenco, copertine, tabella) +e i comandi d'insieme in fondo alla riga, `CatalogSearchField` e filtri rapidi +sotto, elenco con `CATALOG_LIST_CLASSNAME`/`CATALOG_GRID_CLASSNAME`, gruppi +con `CATALOG_GROUP_HEADER_CLASSNAME`, colonna di `ShelfItem` a destra. I +comandi di riga sono sempre `CommandBar`: icone neutre con la descrizione al +passaggio, in gruppi divisi da un filetto, `inline` nella riga a elenco, +`menu` nelle copertine e nella tabella. L'avanzamento è `CompletionBar`. Le +classi condivise stanno in `ui/catalogStyles.ts`. Un catalogo nuovo copia la +Biblioteca pezzo per pezzo: nessuna riga, scaffale o barra di comandi scritta +a mano. + +Nelle Trascrizioni la riga apre con il **nome** della trascrizione in +`font-display` corsivo `text-lg`, e sotto l'opera in forma compatta +(`WorkIdentity` `header`: autore · anno · luogo, tipografo, titolo su una +riga). Se il nome è il titolo dell'opera non si ripete, e la riga torna quella +della Biblioteca (`WorkIdentity` `row`). Il nome si cambia nel posto in cui +sta: il campo sostituisce la riga del nome, Invio salva, Esc o uscire +annullano. + ### Scaffali e filtri rapidi Un catalogo si organizza con una **colonna di scaffali** a destra (larghezza diff --git a/docs/en/guides/keyboard-shortcuts.md b/docs/en/guides/keyboard-shortcuts.md index ea924b63..3c60ff8c 100644 --- a/docs/en/guides/keyboard-shortcuts.md +++ b/docs/en/guides/keyboard-shortcuts.md @@ -12,6 +12,7 @@ field, select control or editable text area. | --- | --- | --- | | `Ctrl + Enter` | Starts the selected translation action | Segment mode requires a selected segment; also works inside text fields | | `Ctrl + S` | Saves the project and modified language resources | Requires an existing project; project saving is deferred while processing | +| `Ctrl + S` in the transcription Studio | Saves a version of the page to the history right away | Also works while typing on the page; does nothing when there is nothing new to save | | `Ctrl + E` | Opens export | Requires at least one segment | | `Ctrl + ,` | Opens pipeline configuration | Outside editable fields | | `Ctrl + H` | Opens this section of the in-app guide | Outside editable fields | diff --git a/docs/en/guides/projects-and-workspace.md b/docs/en/guides/projects-and-workspace.md index 3be569a7..4575c565 100644 --- a/docs/en/guides/projects-and-workspace.md +++ b/docs/en/guides/projects-and-workspace.md @@ -33,6 +33,16 @@ for a stable state. The status bar distinguishes unsaved changes, saving, successful saves and errors. `Ctrl + S` requests a manual save, subject to the conditions described under [keyboard shortcuts](./keyboard-shortcuts). +## Areas and their ink + +Library, Transcriptions and Translations each have their own ink — petrol, +sepia and indigo — so you can tell them apart at a glance: the area icon in the +left sidebar, a short rule under the large title and a slightly different +background paper. State colours stay the same in every area: green marks what +is selected or active, red errors, ochre cautions, gold running jobs. +Translations do not have the Library-style catalogue yet: for now they only +take the title and paper. + ## Dashboard The Dashboard contains an overview, a search across multiple sources, and a diff --git a/docs/en/guides/transcription.md b/docs/en/guides/transcription.md index d1331f14..7e375284 100644 --- a/docs/en/guides/transcription.md +++ b/docs/en/guides/transcription.md @@ -8,9 +8,37 @@ The Transcription Studio is the focused mode for writing and correcting a document's text: viewer on the left, text in the middle, tools on the right. It follows the same idea as Translation, applied to transcription. -## Creating a document - -From the **Transcriptions** area, choose "New document" and give it a title. +## The Transcriptions catalogue + +The **Transcriptions** area gathers the transcriptions of every workspace and +is organised like the Library: shelves on the right, search and quick filters +above the list, three views — list, covers, table — at the end of the title +row. + +Each row shows the transcription name in italics and, below it, the +transcribed work: author, year, place and printer, title. When the name is the +work's own title, which is the name suggested at creation, it is not repeated. +At the end of the row are the workspace, the pages written out of the work's +total, the verified pages and the completion bar. A transcription with no +linked work has no total: it only counts the written pages. + +The shelves are **All**, **Recent** (edited in the last 30 days), **Not +started**, **In progress**, **Verified** (every page verified), **No work** +(started from scratch) and **Archived**; archived transcriptions only appear +on their own shelf. Search looks at the name and at every field of the work. +Quick filters narrow by workspace, library and century of the work, with the +count next to each value; the list sorts by name, author, year, last edit or +progress, and groups by workspace or library. + +Clicking a row opens the Studio. Hovering a row shows its commands: open the +work in the Library, rename — the name turns into a field, Enter saves, Esc +cancels —, archive or restore, delete. In covers and table view the same +commands are in the three-dots menu. Selecting several transcriptions at once +is not available yet. + +## Creating a transcription + +From the **Transcriptions** area, use the **+** command next to the title and give it a name. You can also link it right away to a work already in the Library, searching by title in the same dialog — optional: without it, the document has no viewer, a single block of text. From a work's page in the **Library** you @@ -42,7 +70,11 @@ the Studio. Text saves after 30 seconds without changes. The indicator in the top right distinguishes unsaved text from a save in progress, completed, or failed, with -a command to retry on failure. Changing page or leaving the Studio normally +a command to retry on failure. To save a version to the history right away, +use the disk command next to the indicator, or **Ctrl + S** even while typing +on the page: the command stays off when there is nothing new to save, and the +version is created without a name — pin it in the history to give it one. +Changing page or leaving the Studio normally saves pending text immediately. A forced shutdown before a save can lose the latest edits. diff --git a/docs/guides/keyboard-shortcuts.md b/docs/guides/keyboard-shortcuts.md index f361c677..d7c9eb3a 100644 --- a/docs/guides/keyboard-shortcuts.md +++ b/docs/guides/keyboard-shortcuts.md @@ -12,6 +12,7 @@ un campo di testo, una selezione o un editor modificabile. | --- | --- | --- | | `Ctrl + Invio` | Avvia l’azione di traduzione selezionata | In modalità frammento richiede un frammento selezionato; funziona anche nei campi di testo | | `Ctrl + S` | Salva il progetto e le risorse linguistiche modificate | Richiede un progetto esistente; il progetto non viene salvato mentre è in elaborazione | +| `Ctrl + S` nello Studio di trascrizione | Salva subito una versione della pagina nello storico | Funziona anche mentre scrivi nel foglio; non fa niente se non c’è niente di nuovo da salvare | | `Ctrl + E` | Apre l’esportazione | Richiede almeno un frammento | | `Ctrl + ,` | Apre la configurazione della pipeline | Fuori dai campi modificabili | | `Ctrl + H` | Apre questa sezione della guida in-app | Fuori dai campi modificabili | diff --git a/docs/guides/projects-and-workspace.md b/docs/guides/projects-and-workspace.md index 26dcf571..2cd9dbf1 100644 --- a/docs/guides/projects-and-workspace.md +++ b/docs/guides/projects-and-workspace.md @@ -34,6 +34,16 @@ modifiche da salvare, salvataggio in corso, completamento ed errore. `Ctrl + S` richiede un salvataggio manuale, con i limiti descritti nelle [scorciatoie](./keyboard-shortcuts). +## Aree e loro inchiostro + +Biblioteca, Trascrizioni e Traduzioni hanno ognuna un inchiostro proprio — +petrolio, seppia e indaco — per riconoscerle a colpo d'occhio: l'icona +dell'area nella barra di sinistra, un filetto corto sotto il titolo grande e una +carta di fondo appena diversa. I colori di stato restano gli stessi in ogni +area: il verde segna ciò che è scelto o attivo, il rosso gli errori, l'ocra le +cautele, l'oro i lavori in corso. Le Traduzioni non hanno ancora il catalogo +sul modello della Biblioteca: per ora prendono solo titolo e carta. + ## Dashboard La Dashboard comprende una panoramica, la ricerca su più fonti e la ricerca diff --git a/docs/guides/transcription.md b/docs/guides/transcription.md index 4a5b8bd6..5767e9e2 100644 --- a/docs/guides/transcription.md +++ b/docs/guides/transcription.md @@ -8,9 +8,37 @@ Lo Studio di trascrizione è la modalità concentrata per scrivere e correggere il testo di un documento: visore a sinistra, testo al centro, strumenti a destra. È la stessa idea della Traduzione, applicata alla trascrizione. -## Creare un documento - -Dall'area **Trascrizioni** scegli "Nuovo documento" e dai un titolo. Puoi +## Il catalogo delle Trascrizioni + +L'area **Trascrizioni** raccoglie le trascrizioni di tutti i workspace ed è +organizzata come la Biblioteca: scaffali a destra, ricerca e filtri rapidi +sopra l'elenco, tre viste — elenco, copertine, tabella — in fondo alla riga +del titolo. + +Ogni riga mostra il nome della trascrizione in corsivo e, sotto, l'opera +trascritta: autore, anno, luogo e tipografo, titolo. Se il nome è lo stesso +titolo dell'opera, che è il nome proposto alla creazione, non si ripete. In +fondo alla riga stanno il workspace, le pagine scritte sul totale dell'opera, +le pagine verificate e la barretta di completamento. Una trascrizione senza +opera collegata non ha un totale: conta solo le pagine scritte. + +Gli scaffali sono **Tutte**, **Recenti** (modificate negli ultimi 30 giorni), +**Da iniziare**, **In corso**, **Verificate** (tutte le pagine verificate), +**Senza opera** (nate da zero) e **Archiviate**; le archiviate compaiono solo +nel loro scaffale. La ricerca guarda il nome e tutti i dati dell'opera. I +filtri rapidi restringono per workspace, biblioteca e secolo dell'opera, con +il conteggio accanto a ogni valore; l'elenco si ordina per nome, autore, anno, +ultima modifica o avanzamento, e si raggruppa per workspace o biblioteca. + +Un click sulla riga apre lo Studio. Passando sulla riga compaiono i comandi: +apri l'opera in Biblioteca, rinomina — il nome diventa un campo, Invio salva, +Esc annulla —, archivia o ripristina, elimina. Nelle copertine e nella +tabella gli stessi comandi stanno nel menu con i tre puntini. Non c'è ancora +la scelta di più trascrizioni insieme. + +## Creare una trascrizione + +Dall'area **Trascrizioni** usa il comando **+** accanto al titolo e dai un nome. Puoi anche collegarlo subito a un'opera già in Biblioteca cercandola per titolo nello stesso dialogo — facoltativo: senza, il documento resta senza visore, un solo blocco di testo. Dalla scheda di un'opera in **Biblioteca** puoi @@ -42,7 +70,12 @@ Biblioteca, non dello Studio. Il testo si salva dopo 30 secondi senza modifiche. L'indicatore in alto a destra distingue il testo ancora da salvare dal salvataggio in corso, riuscito -o fallito, con un comando per riprovare in caso di errore. Cambiando pagina o +o fallito, con un comando per riprovare in caso di errore. Per salvare subito +una versione nello storico usa il comando con il dischetto accanto +all'indicatore, oppure **Ctrl + S** anche mentre scrivi nel foglio: il comando +resta spento quando non c'è niente di nuovo da salvare, e la versione nasce +senza nome — per fissarla con un nome si usa la puntina nello storico. +Cambiando pagina o uscendo normalmente dallo Studio, il testo ancora da salvare viene scritto subito. Una chiusura forzata prima del salvataggio può perdere le ultime modifiche. diff --git a/src/components/layout/shell-next/WorkspaceRailNext.tsx b/src/components/layout/shell-next/WorkspaceRailNext.tsx index d1a54886..a697a1b9 100644 --- a/src/components/layout/shell-next/WorkspaceRailNext.tsx +++ b/src/components/layout/shell-next/WorkspaceRailNext.tsx @@ -17,12 +17,17 @@ import { useWorkspaceStore } from '../../../stores/workspaceStore'; import { useUiStore } from '../../../stores/uiStore'; import { dashboardLocation, workspaceLocation, type GlobalArea } from '../../../navigation/appLocation'; import type { Workspace } from '../../../types'; -import { IconButton } from '../../ui'; +import { AREA_INK_CLASSNAME, IconButton, type InkedArea } from '../../ui'; import { CreateWorkspaceDialog } from '../../workspace/CreateWorkspaceDialog'; import { ShellNavItem, ShellNavSection } from '../ShellNav'; import { RailBrandToggle } from './RailBrandToggle'; import { WorkspaceIcon } from '../../workspace/WorkspaceIdentity'; +/** Le aree con un inchiostro proprio: la loro icona lo porta anche a riposo. */ +function isInkedArea(area: GlobalArea): area is InkedArea { + return area in AREA_INK_CLASSNAME; +} + const AREA_ITEMS: ReadonlyArray<{ id: GlobalArea; icon: typeof BookOpenText; enabled: boolean }> = [ { id: 'library', icon: LibraryBig, enabled: true }, { id: 'translations', icon: BookOpenText, enabled: true }, @@ -144,7 +149,8 @@ function AreaSection({ collapsed }: { collapsed: boolean }) { active ? 'border-editorial-accent text-editorial-accent' : enabled - ? 'border-editorial-border bg-editorial-textbox/30 text-editorial-muted hover:border-editorial-accent/30 hover:text-editorial-accent' + ? `border-editorial-border bg-editorial-textbox/30 hover:border-editorial-accent/30 hover:text-editorial-accent ${ + isInkedArea(id) ? AREA_INK_CLASSNAME[id] : 'text-editorial-muted'}` : 'border-editorial-border bg-editorial-textbox/30 text-editorial-muted' }`} > diff --git a/src/components/transcription/TranscriptionCatalogRow.tsx b/src/components/transcription/TranscriptionCatalogRow.tsx new file mode 100644 index 00000000..01217359 --- /dev/null +++ b/src/components/transcription/TranscriptionCatalogRow.tsx @@ -0,0 +1,166 @@ +import { useEffect, useRef, useState } from 'react'; +import { FileText } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { AREA_INK_CLASSNAME, CommandBar, CompletionBar, Tooltip, type RowCommand } from '../ui'; +import { FIELD_CLASSNAME } from '../ui/fieldStyles'; +import { ROW_REVEAL_CLASSNAME } from '../ui/catalogStyles'; +import { CachedThumbnail } from '../common/CachedThumbnail'; +import { WorkIdentity, type WorkIdentityData } from '../common/WorkIdentity'; +import { DOCUMENT_TITLE_MAX } from '../../services/transcriptionService'; +import type { TranscriptionCatalogEntry } from '../../services/transcriptionCatalogService'; +import { progressOf, totalPagesOf } from '../../utils/transcriptionCatalogFilters'; + +export interface TranscriptionRowProps { + entry: TranscriptionCatalogEntry; + workspaceName: string; + commands: RowCommand[][]; + renaming: boolean; + onRename: (title: string) => void; + onRenameCancel: () => void; + onOpen: () => void; +} + +export function workOf(entry: TranscriptionCatalogEntry): WorkIdentityData | null { + const work = entry.work; + if (!work) return null; + return { + title: work.source.title, + creator: work.fields.creator, + date: work.fields.date, + place: work.fields.origin_place, + publisher: work.fields.publisher, + }; +} + +/** A che punto è: pagine scritte (sul totale, se si sa) e verificate. */ +export function useProgressFacts(entry: TranscriptionCatalogEntry) { + const { t } = useTranslation(); + const total = totalPagesOf(entry); + const written = total + ? t('areas.transcriptions.catalog.pagesWritten', { done: entry.pagesWithText, total }) + : t('areas.transcriptions.catalog.pagesWrittenCount', { count: entry.pagesWithText }); + const verified = entry.verifiedPages > 0 + ? t('areas.transcriptions.catalog.verifiedCount', { count: entry.verifiedPages }) + : null; + return { total, written, verified }; +} + +/** Il nome scritto dove compare: Invio salva, Esc o un click fuori annullano. */ +export function RenameField({ initial, onSave, onCancel, className = '' }: { + initial: string; + onSave: (title: string) => void; + onCancel: () => void; + className?: string; +}) { + const { t } = useTranslation(); + const [title, setTitle] = useState(initial); + const input = useRef(null); + useEffect(() => { input.current?.select(); }, []); + const save = () => { + const trimmed = title.trim(); + if (!trimmed || trimmed === initial) onCancel(); + else onSave(trimmed); + }; + return ( + setTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') save(); + if (event.key === 'Escape') onCancel(); + }} + onBlur={onCancel} + aria-label={t('areas.transcriptions.catalog.renameLabel')} + className={`${FIELD_CLASSNAME} py-1 font-display text-base italic ${className}`} + /> + ); +} + +/** + * Una trascrizione nel suo catalogo, sul modello della riga della Biblioteca: + * il nome in corsivo, sotto l'opera trascritta in forma compatta (autore · + * anno · tipografo, titolo), poi workspace e avanzamento. Quando il nome è il + * titolo dell'opera non si ripete: la riga mostra l'opera come in Biblioteca. + * Un click apre lo Studio; i comandi compaiono al passaggio o col fuoco. + */ +export function TranscriptionCatalogRow({ entry, workspaceName, commands, renaming, onRename, onRenameCancel, onOpen, view }: + TranscriptionRowProps & { view: 'list' | 'grid' }) { + const work = workOf(entry); + const { total, written, verified } = useProgressFacts(entry); + const isGrid = view === 'grid'; + const archived = entry.document.status === 'archived'; + const nameIsWorkTitle = work !== null && work.title.trim() === entry.document.title.trim(); + const progress = progressOf(entry); + + const details = ( + <> + {[workspaceName, written, verified].filter(Boolean).join(' · ')} + {total && entry.pagesWithText > 0 && } + + ); + const name = ( + + + {entry.document.title} + + + ); + const identity = work === null ? ( + + {name} + {details} + + ) : nameIsWorkTitle && !renaming ? ( + + ) : ( + + {!renaming && name} + + + ); + const cover = ( + + + ); + + return ( +
+ {renaming ? ( +
+ {cover} + + + {identity} + +
+ ) : ( + + )} +
+ +
+
+ ); +} diff --git a/src/components/transcription/TranscriptionCatalogTable.tsx b/src/components/transcription/TranscriptionCatalogTable.tsx new file mode 100644 index 00000000..f214fb87 --- /dev/null +++ b/src/components/transcription/TranscriptionCatalogTable.tsx @@ -0,0 +1,94 @@ +import { useTranslation } from 'react-i18next'; +import { CommandBar, Tooltip } from '../ui'; +import { ROW_REVEAL_CLASSNAME } from '../ui/catalogStyles'; +import type { TranscriptionCatalogEntry } from '../../services/transcriptionCatalogService'; +import type { TranscriptionSort } from '../../utils/transcriptionCatalogFilters'; +import { RenameField, useProgressFacts, type TranscriptionRowProps } from './TranscriptionCatalogRow'; + +/** Le colonne che si ordinano cliccando l'intestazione, con l'ordine che scelgono. */ +const SORTABLE: Partial> = { + name: 'title', creator: 'creator', year: 'year', progress: 'progress', +}; +const COLUMNS = ['name', 'creator', 'work', 'year', 'workspace', 'provider', 'progress', 'verified'] as const; + +function TableRow({ entry, workspaceName, providerLabel, commands, renaming, onRename, onRenameCancel, onOpen }: + TranscriptionRowProps & { providerLabel?: string }) { + const { written } = useProgressFacts(entry); + const work = entry.work; + return ( + + + {renaming ? ( + + ) : ( + + + + )} + + {work?.fields.creator} + + {work && ( + + {work.source.title} + + )} + + {work?.fields.date} + {workspaceName} + {providerLabel} + {written} + {entry.verifiedPages || ''} + + + + + ); +} + +/** + * Le trascrizioni come tabella, sul modello di quella della Biblioteca: una + * riga per trascrizione, nome, opera, workspace e avanzamento. + */ +export function TranscriptionCatalogTable({ entries, sort, onSort, rowPropsFor, providerLabel }: { + entries: TranscriptionCatalogEntry[]; + sort: TranscriptionSort; + onSort: (sort: TranscriptionSort) => void; + rowPropsFor: (entry: TranscriptionCatalogEntry) => TranscriptionRowProps; + providerLabel: (entry: TranscriptionCatalogEntry) => string | undefined; +}) { + const { t } = useTranslation(); + return ( + + + + {COLUMNS.map((column) => { + const sortKey = SORTABLE[column]; + const label = t(`areas.transcriptions.catalog.table.${column}`); + return ( + + ); + })} + + + + {entries.map((entry) => ( + + ))} + +
+ {sortKey ? ( + + ) : label} + +
+ ); +} diff --git a/src/components/transcription/TranscriptionQuickFilters.tsx b/src/components/transcription/TranscriptionQuickFilters.tsx new file mode 100644 index 00000000..c094f2bb --- /dev/null +++ b/src/components/transcription/TranscriptionQuickFilters.tsx @@ -0,0 +1,96 @@ +import { Eraser } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { CatalogSearchField, IconButton, Select } from '../ui'; +import { ACTIVE_FILTER_CLASSNAME, QUICK_FILTER_CLASSNAME } from '../ui/catalogStyles'; +import { romanNumeral } from '../../utils/workYear'; +import { + EMPTY_TRANSCRIPTION_FILTERS, + TRANSCRIPTION_FACETS, + TRANSCRIPTION_GROUPINGS, + TRANSCRIPTION_SORTS, + hasActiveTranscriptionFilters, + type TranscriptionFacet, + type TranscriptionFilters, + type TranscriptionGrouping, + type TranscriptionSort, +} from '../../utils/transcriptionCatalogFilters'; + +/** + * Sopra l'elenco, come in Biblioteca: la ricerca in una riga sua, sotto un + * filtro rapido per aspetto con quante trascrizioni ha ogni valore, l'ordine e + * il raggruppamento. + */ +export function TranscriptionQuickFilters({ filters, onChange, counts, providerLabel, workspaceName, grouping, onGrouping }: { + filters: TranscriptionFilters; + onChange: (filters: TranscriptionFilters) => void; + counts: Record>; + providerLabel: (key: string) => string; + workspaceName: (id: string) => string; + grouping: TranscriptionGrouping; + onGrouping: (grouping: TranscriptionGrouping) => void; +}) { + const { t } = useTranslation(); + + const valueLabel = (facet: TranscriptionFacet, value: string): string => { + switch (facet) { + case 'century': return t('areas.library.filters.centuryValue', { century: romanNumeral(Number(value)) }); + case 'providerKey': return providerLabel(value); + case 'workspaceId': return workspaceName(value); + } + }; + + const facetOptions = (facet: TranscriptionFacet) => { + const values = [...counts[facet].keys()]; + if (filters[facet] && !values.includes(filters[facet])) values.push(filters[facet]); + const sorted = facet === 'century' + ? values.sort((a, b) => Number(a) - Number(b)) + : values.sort((a, b) => valueLabel(facet, a).localeCompare(valueLabel(facet, b))); + return [ + { value: '', label: t(`areas.transcriptions.catalog.filters.all.${facet}`) }, + ...sorted.map((value) => ({ value, label: `${valueLabel(facet, value)} (${counts[facet].get(value) ?? 0})` })), + ]; + }; + + return ( +
+ onChange({ ...filters, query })} + placeholder={t('areas.transcriptions.catalog.filters.searchPlaceholder')} + label={t('areas.transcriptions.catalog.filters.searchLabel')} + /> +
+ {TRANSCRIPTION_FACETS.filter((facet) => counts[facet].size > 0 || filters[facet]).map((facet) => ( + onChange({ ...filters, sort: sort as TranscriptionSort })} + options={TRANSCRIPTION_SORTS.map((sort) => ({ value: sort, label: t(`areas.transcriptions.catalog.filters.sort.${sort}`) }))} + ariaLabel={t('areas.transcriptions.catalog.filters.sortLabel')} + className={QUICK_FILTER_CLASSNAME} + /> + onChange(event.target.value)} + placeholder={placeholder} + aria-label={label} + className={`${FIELD_CLASSNAME} py-1.5 pl-8 text-xs`} + /> +
+ ); +} diff --git a/src/components/ui/CatalogViewSwitch.tsx b/src/components/ui/CatalogViewSwitch.tsx new file mode 100644 index 00000000..4c12c444 --- /dev/null +++ b/src/components/ui/CatalogViewSwitch.tsx @@ -0,0 +1,26 @@ +import { LayoutGrid, List, Table2, type LucideIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { IconButton } from './IconButton'; + +export type CatalogView = 'list' | 'grid' | 'table'; + +const VIEWS: { view: CatalogView; icon: LucideIcon; labelKey: string }[] = [ + { view: 'list', icon: List, labelKey: 'areas.library.viewList' }, + { view: 'grid', icon: LayoutGrid, labelKey: 'areas.library.viewGrid' }, + { view: 'table', icon: Table2, labelKey: 'areas.library.viewTable' }, +]; + +/** Elenco, copertine o tabella: in fondo alla riga del titolo grande. */ +export function CatalogViewSwitch({ view, onChange }: { view: CatalogView; onChange: (view: CatalogView) => void }) { + const { t } = useTranslation(); + return ( +
+ {VIEWS.map(({ view: option, icon: Icon, labelKey }) => ( + onChange(option)} + title={t(labelKey)} ariaPressed={view === option}> + + + ))} +
+ ); +} diff --git a/src/components/ui/CommandBar.tsx b/src/components/ui/CommandBar.tsx new file mode 100644 index 00000000..fdb27946 --- /dev/null +++ b/src/components/ui/CommandBar.tsx @@ -0,0 +1,113 @@ +import { Fragment, useState, type ReactNode } from 'react'; +import { MoreVertical } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { ClickPopover } from './ClickPopover'; +import { IconButton } from './IconButton'; +import { MenuActionRow } from './MenuActionRow'; +import { COMMAND_DIVIDER_CLASSNAME } from './catalogStyles'; + +export interface RowCommand { + key: string; + icon: ReactNode; + label: string; + onClick: () => void; + disabled?: boolean; + tone?: 'danger'; +} + +interface CommandBarProps { + /** Gruppi di comandi, nell'ordine della fila; un filetto li divide. */ + groups: RowCommand[][]; + /** + * `inline` mette i comandi in fila come icone, per la riga a elenco; `menu` + * li raccoglie in un menu, dove lo spazio è poco (copertine, tabella). + */ + variant?: 'inline' | 'menu'; + size?: 'sm' | 'md'; + /** Primo gruppo della fila, già fatto da chi chiama (`inline`). */ + leading?: ReactNode; + /** Uno stato che resta acceso davanti ai comandi (un lavoro in corso). */ + status?: ReactNode; +} + +/** + * I comandi di una riga di catalogo, uguali in ogni elenco: icone neutre con + * la descrizione al passaggio, in gruppi divisi da un filetto, oppure lo + * stesso elenco in un menu. + */ +export function CommandBar({ groups, variant = 'inline', size = 'sm', leading, status }: CommandBarProps) { + const { t } = useTranslation(); + const [menuOpen, setMenuOpen] = useState(false); + const filled = groups.filter((group) => group.length > 0); + + if (variant === 'inline') { + const rendered = [ + ...(leading ? [leading] : []), + ...filled.map((group) => group.map((command) => )), + ]; + return ( +
+ {status} + {rendered.map((group, index) => ( + + {index > 0 && + ))} +
+ ); + } + + const menuRow = (command: RowCommand) => ( + { + setMenuOpen(false); + command.onClick(); + }} + disabled={command.disabled} + tone={command.tone} + /> + ); + + return ( +
+ {status} + + + + } + > +
+ {filled.map((group, index) => ( + + {index > 0 &&
} + {group.map(menuRow)} + + ))} +
+ +
+ ); +} + +/** Neutro anche per eliminare: la conferma arriva dopo, e una fila di icone + * con una rossa in fondo attirerebbe l'occhio sul comando più raro. */ +function CommandButton({ command, size }: { command: RowCommand; size: 'sm' | 'md' }) { + return ( + + {command.icon} + + ); +} diff --git a/src/components/ui/CompletionBar.tsx b/src/components/ui/CompletionBar.tsx new file mode 100644 index 00000000..7402df8d --- /dev/null +++ b/src/components/ui/CompletionBar.tsx @@ -0,0 +1,25 @@ +/** + * Quanto di una cosa è fatto, dentro una riga di elenco: barra corta a + * larghezza fissa e il conteggio accanto. Verde a completamento pieno, oro a + * metà. È un dato fra gli altri, non si stende per tutta la riga. + */ +export function CompletionBar({ ratio, label, complete }: { + ratio: number; + label: string; + /** Chi conosce lo stato vero lo dice; altrimenti vale il rapporto pieno. */ + complete?: boolean; +}) { + const clamped = Math.min(1, Math.max(0, ratio)); + const isComplete = complete ?? clamped >= 1; + return ( + <> +