diff --git a/web/src/components/external-uri-link.tsx b/web/src/components/external-uri-link.tsx new file mode 100644 index 0000000..3d9799a --- /dev/null +++ b/web/src/components/external-uri-link.tsx @@ -0,0 +1,12 @@ +export function ExternalUriLink({ uri }: { uri: string }) { + return ( + + {uri} + + ); +} diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 54e52b6..5a0795e 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -1,5 +1,5 @@ { - "common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading" }, + "common": { "yes": "Yes", "no": "No", "close": "Close", "loading": "Loading", "filter": "Filter…", "noMatches": "No matches" }, "nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "collapseSidebar": "Collapse sidebar", "expandSidebar": "Expand sidebar" }, "auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" }, "objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of", "new": "New object", "filter": "Filter objects…", "pageSize": "Per page", "columns": { "number": "Object №", "name": "Name", "visibility": "Visibility", "location": "Location", "count": "#", "updated": "Updated" }, "unknownRef": "(unknown)" }, @@ -7,7 +7,7 @@ "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "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", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "createdButFieldRejected": "Object created, but a field was rejected — fix it below.", "flexibleHeading": "Catalogue fields", "saving": "Saving…", "createAnother": "Save & create another", "minCount": "Must be at least 1", "fieldError": { "type_mismatch": "Wrong type for this field", "unresolved": "Referenced value not found", "unknown": "Unknown field" }, "unsaved": { "title": "Discard unsaved changes?", "body": "You have unsaved changes that will be lost.", "stay": "Keep editing", "leave": "Discard" } }, "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." }, - "labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept." }, + "labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept.", "uriPlaceholder": "https://…" }, "theme": { "light": "Light", "dark": "Dark", "system": "System" }, "vocab": { "newVocabulary": "New vocabulary", "key": "Key", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index aae9a44..b3c8627 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -1,5 +1,5 @@ { - "common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar" }, + "common": { "yes": "Ja", "no": "Nej", "close": "Stäng", "loading": "Laddar", "filter": "Filtrera…", "noMatches": "Inga träffar" }, "nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "collapseSidebar": "Fäll ihop sidofältet", "expandSidebar": "Fäll ut sidofältet" }, "auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" }, "objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av", "new": "Nytt föremål", "filter": "Filtrera föremål…", "pageSize": "Per sida", "columns": { "number": "Föremålsnr", "name": "Namn", "visibility": "Synlighet", "location": "Plats", "count": "Antal", "updated": "Uppdaterad" }, "unknownRef": "(okänd)" }, @@ -7,7 +7,7 @@ "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "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", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "createdButFieldRejected": "Föremålet skapades, men ett fält avvisades — åtgärda nedan.", "flexibleHeading": "Katalogfält", "saving": "Sparar…", "createAnother": "Spara & skapa ny", "minCount": "Måste vara minst 1", "fieldError": { "type_mismatch": "Fel typ för detta fält", "unresolved": "Refererat värde hittades inte", "unknown": "Okänt fält" }, "unsaved": { "title": "Kasta osparade ändringar?", "body": "Du har osparade ändringar som går förlorade.", "stay": "Fortsätt redigera", "leave": "Kasta" } }, "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." }, - "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls." }, + "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls.", "uriPlaceholder": "https://…" }, "theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" }, "vocab": { "newVocabulary": "Ny vokabulär", "key": "Nyckel", diff --git a/web/src/lib/sort.test.ts b/web/src/lib/sort.test.ts new file mode 100644 index 0000000..78b0170 --- /dev/null +++ b/web/src/lib/sort.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; + +import { byKey, byLabel, compareStrings } from "./sort"; + +const L = (label: string) => ({ labels: [{ lang: "en", label }] }); + +test("byLabel sorts case-insensitively and locale-aware", () => { + const sorted = [L("Iron"), L("bronze"), L("Amber")].sort(byLabel("en")).map((x) => x.labels[0].label); + + expect(sorted).toEqual(["Amber", "bronze", "Iron"]); +}); + +test("byKey sorts keys with numeric awareness", () => { + const sorted = [{ key: "item10" }, { key: "item2" }, { key: "item1" }].sort(byKey("en")).map((x) => x.key); + + expect(sorted).toEqual(["item1", "item2", "item10"]); +}); + +test("compareStrings is case-insensitive", () => { + expect(compareStrings("en", "bronze", "BRONZE")).toBe(0); +}); diff --git a/web/src/lib/sort.ts b/web/src/lib/sort.ts new file mode 100644 index 0000000..8cc0de6 --- /dev/null +++ b/web/src/lib/sort.ts @@ -0,0 +1,31 @@ +import type { components } from "../api/schema"; + +import { labelText } from "./labels"; + +type LabelView = components["schemas"]["LabelView"]; + +const collators = new Map(); + +function collatorFor(lang: string): Intl.Collator { + let c = collators.get(lang); + + if (!c) { + c = new Intl.Collator(lang, { sensitivity: "base", numeric: true }); + collators.set(lang, c); + } + + return c; +} + +export function compareStrings(lang: string, a: string, b: string): number { + return collatorFor(lang).compare(a, b); +} + +export function byLabel(lang: string) { + return (a: { labels: LabelView[] }, b: { labels: LabelView[] }) => + compareStrings(lang, labelText(a.labels, lang), labelText(b.labels, lang)); +} + +export function byKey(lang: string) { + return (a: { key: string }, b: { key: string }) => compareStrings(lang, a.key, b.key); +}