diff --git a/web/src/app.tsx b/web/src/app.tsx index 5508f09..e665902 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -7,6 +7,9 @@ import { AppShell } from "./shell/app-shell"; import { ObjectsPage } from "./objects/objects-page"; import { ObjectDetail } from "./objects/object-detail"; import { SelectPrompt } from "./objects/select-prompt"; +import { VocabulariesPage } from "./vocab/vocabularies-page"; +import { VocabularyTerms } from "./vocab/vocabulary-terms"; +import { SelectVocabularyPrompt } from "./vocab/select-vocabulary-prompt"; const ObjectNewPage = lazy(() => import("./objects/object-new-page").then((m) => ({ default: m.ObjectNewPage })), @@ -47,6 +50,10 @@ export function App() { } /> + }> + } /> + } /> + } /> diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index fed225e..d0df41c 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -8,6 +8,12 @@ "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "flexibleHeading": "Catalogue fields" }, "actions": { "edit": "Edit", "delete": "Delete", "confirmDelete": "Delete this object? This cannot be undone." }, "labels": { "en": "Label (EN)", "sv": "Label (SV)", "externalUri": "External URI (optional)" }, + "vocab": { + "title": "Vocabularies", "newVocabulary": "New vocabulary", "key": "Key", + "create": "Create", "selectPrompt": "Select a vocabulary to manage its terms", + "terms": "Terms", "addTerm": "Add term", "empty": "No vocabularies yet", + "noTerms": "No terms yet", "loadError": "Could not load" + }, "publish": { "heading": "Visibility", "advanceInternal": "Advance to internal", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 87427be..61fba36 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -8,6 +8,12 @@ "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "flexibleHeading": "Katalogfält" }, "actions": { "edit": "Redigera", "delete": "Ta bort", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras." }, "labels": { "en": "Etikett (EN)", "sv": "Etikett (SV)", "externalUri": "Extern URI (valfritt)" }, + "vocab": { + "title": "Vokabulär", "newVocabulary": "Ny vokabulär", "key": "Nyckel", + "create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer", + "terms": "Termer", "addTerm": "Lägg till term", "empty": "Inga vokabulärer ännu", + "noTerms": "Inga termer ännu", "loadError": "Kunde inte ladda" + }, "publish": { "heading": "Synlighet", "advanceInternal": "Gör intern", diff --git a/web/src/shell/app-shell.tsx b/web/src/shell/app-shell.tsx index 2ff6c69..68c0286 100644 --- a/web/src/shell/app-shell.tsx +++ b/web/src/shell/app-shell.tsx @@ -5,7 +5,7 @@ import { useLogout } from "../api/queries"; import { Button } from "@/components/ui/button"; import { LangSwitch } from "./lang-switch"; -const FUTURE = ["vocabularies", "authorities", "fields", "search"] as const; +const DISABLED_NAV = ["authorities", "fields", "search"] as const; export function AppShell() { const { t } = useTranslation(); @@ -30,7 +30,15 @@ export function AppShell() { > {t("nav.objects")} - {FUTURE.map((key) => ( + + `block rounded px-2 py-1 ${isActive ? "bg-neutral-200 font-medium" : ""}` + } + > + {t("nav.vocabularies")} + + {DISABLED_NAV.map((key) => ( + + + + + ); +} diff --git a/web/src/vocab/vocabulary-terms.tsx b/web/src/vocab/vocabulary-terms.tsx new file mode 100644 index 0000000..b2f1721 --- /dev/null +++ b/web/src/vocab/vocabulary-terms.tsx @@ -0,0 +1,94 @@ +import { useState, type FormEvent } from "react"; +import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +import type { components } from "../api/schema"; +import { useTerms, useAddTerm } from "../api/queries"; +import { LabelEditor } from "../components/label-editor"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +type LabelInput = components["schemas"]["LabelInput"]; +type LabelView = components["schemas"]["LabelView"]; + +function labelText(labels: LabelView[], lang: string): string { + return ( + labels.find((l) => l.lang === lang)?.label ?? + labels.find((l) => l.lang === "en")?.label ?? + labels[0]?.label ?? + "" + ); +} + +export function VocabularyTerms() { + const { t, i18n } = useTranslation(); + + const { id } = useParams(); + + const lang = i18n.language.startsWith("sv") ? "sv" : "en"; + + const { data: terms } = useTerms(id); + + const addTerm = useAddTerm(); + + const [labels, setLabels] = useState([]); + + const [uri, setUri] = useState(""); + + const [error, setError] = useState(false); + + const onAdd = (event: FormEvent) => { + event.preventDefault(); + + if (!labels.some((l) => l.lang === "en" && l.label)) { + setError(true); + return; + } + + setError(false); + + addTerm.mutate( + { vocabularyId: id!, external_uri: uri.trim() || null, labels }, + { onSuccess: () => { setLabels([]); setUri(""); } }, + ); + }; + + return ( +
+

+ {t("vocab.terms")} +

+
    + {terms?.length === 0 && ( +
  • {t("vocab.noTerms")}
  • + )} + {terms?.map((term) => ( +
  • + {labelText(term.labels, lang)} +
  • + ))} +
+
+
{t("vocab.addTerm")}
+ +
+ + setUri(e.target.value)} + /> +
+ {error && ( +

+ {t("form.required")} +

+ )} + + +
+ ); +}