From 9e7c298c47c7f72338f3656fce39a23482d8eabc Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 1 Jul 2026 10:00:53 +0200 Subject: [PATCH 01/28] wip(markers): initial frontend implementation of marker editor --- .../app/components/markers/MarkersEditor.vue | 194 ++++++++++ .../components/markers/MarkersListItem.vue | 53 +++ .../components/markers/MarkersTimeline.vue | 115 ++++++ frontend/app/composables/useMarkers.ts | 111 ++++++ frontend/app/composables/useTools.ts | 157 ++++---- frontend/app/pages/markers/[id].vue | 342 ++++++++++++++++++ frontend/app/pages/markers/index.vue | 56 +++ frontend/app/utils/markers.ts | 60 +++ frontend/locales/en.json | 55 +++ frontend/locales/nb.json | 55 +++ 10 files changed, 1128 insertions(+), 70 deletions(-) create mode 100644 frontend/app/components/markers/MarkersEditor.vue create mode 100644 frontend/app/components/markers/MarkersListItem.vue create mode 100644 frontend/app/components/markers/MarkersTimeline.vue create mode 100644 frontend/app/composables/useMarkers.ts create mode 100644 frontend/app/pages/markers/[id].vue create mode 100644 frontend/app/pages/markers/index.vue create mode 100644 frontend/app/utils/markers.ts diff --git a/frontend/app/components/markers/MarkersEditor.vue b/frontend/app/components/markers/MarkersEditor.vue new file mode 100644 index 0000000..e9afc54 --- /dev/null +++ b/frontend/app/components/markers/MarkersEditor.vue @@ -0,0 +1,194 @@ + + + diff --git a/frontend/app/components/markers/MarkersListItem.vue b/frontend/app/components/markers/MarkersListItem.vue new file mode 100644 index 0000000..cef6021 --- /dev/null +++ b/frontend/app/components/markers/MarkersListItem.vue @@ -0,0 +1,53 @@ + + + diff --git a/frontend/app/components/markers/MarkersTimeline.vue b/frontend/app/components/markers/MarkersTimeline.vue new file mode 100644 index 0000000..0dd5b9b --- /dev/null +++ b/frontend/app/components/markers/MarkersTimeline.vue @@ -0,0 +1,115 @@ + + + diff --git a/frontend/app/composables/useMarkers.ts b/frontend/app/composables/useMarkers.ts new file mode 100644 index 0000000..1875008 --- /dev/null +++ b/frontend/app/composables/useMarkers.ts @@ -0,0 +1,111 @@ +import { useLocalStorage } from "@vueuse/core"; +import type { Marker, MarkerType } from "~/utils/markers"; + +// Frontend-only data layer for the markers tool. +// +// Working state is auto-persisted to localStorage (keyed by VX-id), mirroring +// the transcription editor's local-autosave behaviour. `save()` currently just +// simulates the round-trip to the backend. +// +// TODO(backend): replace the seed/load/save internals with the real RPCs, e.g. +// load -> api.getMarkers({ VXID }) +// save -> api.submitMarkers({ VXID, markers }) +// The public surface of this composable should not need to change. + +// Demo "imported" markers, standing in for the third-party timing feed. Times +// are kept within the first few minutes so they line up with most previews. +function seedMarkers(): Marker[] { + return [ + { + id: generateRandomId(), + type: "name-super", + label: "Kåre Johan Hamre", + note: "Speaker", + start: 12.5, + end: 18.0, + source: "imported", + }, + { + id: generateRandomId(), + type: "bible-verse", + label: "John 3:16", + start: 45.0, + end: 52.5, + source: "imported", + }, + { + id: generateRandomId(), + type: "name-super", + label: "Bente Lothe", + start: 95.2, + end: 101.0, + source: "imported", + }, + { + id: generateRandomId(), + type: "song", + label: "How Great Thou Art", + start: 130.0, + end: 240.0, + source: "imported", + }, + ]; +} + +export function useMarkers(vxId: MaybeRefOrGetter) { + // Plain string key, evaluated once. Each asset is opened in a fresh mount + // (via the index page), so we don't need a reactive key here. + const storageKey = `markers-${toValue(vxId)}`; + + // `useLocalStorage` seeds with the imported demo data on first visit, then + // reads the working copy on subsequent loads. Its internal deep watcher + // persists every mutation automatically. + const markers = useLocalStorage(storageKey, seedMarkers()); + + function add( + partial: Partial & Pick, + ): Marker { + const marker: Marker = { + id: generateRandomId(), + type: "name-super", + label: "", + source: "manual", + ...partial, + }; + markers.value = [...markers.value, marker]; + return marker; + } + + function update(id: string, patch: Partial>) { + markers.value = markers.value.map((m) => + m.id === id ? { ...m, ...patch } : m, + ); + } + + function remove(id: string) { + markers.value = markers.value.filter((m) => m.id !== id); + } + + // Restore a previously removed marker (used by the undo toast). + function restore(marker: Marker) { + if (markers.value.some((m) => m.id === marker.id)) return; + markers.value = [...markers.value, marker]; + } + + const saving = ref(false); + // Simulated submit-to-backend. Swap for `api.submitMarkers` later. + async function save() { + saving.value = true; + try { + await new Promise((resolve) => setTimeout(resolve, 400)); + } finally { + saving.value = false; + } + } + + function countByType(type: MarkerType) { + return markers.value.filter((m) => m.type === type).length; + } + + return { markers, add, update, remove, restore, save, saving, countByType }; +} diff --git a/frontend/app/composables/useTools.ts b/frontend/app/composables/useTools.ts index b8de3f5..a7842a7 100644 --- a/frontend/app/composables/useTools.ts +++ b/frontend/app/composables/useTools.ts @@ -1,79 +1,96 @@ interface Tool { - label: string; - icon: string; - description: string; - to: string; - enabled?: boolean; + label: string; + icon: string; + description: string; + to: string; + enabled?: boolean; } export function useTools() { - const { t } = useI18n(); - const { me } = useMe(); - const route = useRoute() + const { t } = useI18n(); + const { me } = useMe(); + const route = useRoute(); - const tools = computed(() => [ - { - label: t("tools.bmmUpload.title"), - icon: "tabler:upload", - description: t("tools.bmmUpload.description"), - to: "/upload/bmm/", - enabled: me.value?.bmm && (me.value.bmm.podcasts.length > 0 || me.value.bmm.admin), - }, - { - label: t("tools.transcription.title"), - icon: "tabler:edit", - description: t("tools.transcription.description"), - to: "/transcription/", - enabled: me.value?.transcription && (me.value.transcription.mediabanken || me.value.transcription.admin), - }, - { - label: t("tools.export.title"), - icon: "tabler:file-export", - description: t("tools.export.description"), - to: "/export/", - enabled: me.value?.admin || (me.value?.export && (me.value.export.destinations.length > 0 || me.value.export.admin || me.value.export.timedMetadata)), - }, - { - label: t("tools.vbExport.title"), - icon: "tabler:broadcast", - description: t("tools.vbExport.description"), - to: "/vb-export/", - // Shown when the user has access to any VB destination (or is on the page). - enabled: - me.value?.admin || - (me.value?.vbExport && (me.value.vbExport.destinations.length > 0 || me.value.vbExport.admin)) || - route.path.startsWith("/vb-export"), - }, - { - label: 'Shorts generation', - icon: "tabler:device-mobile", - description: 'Generate shorts from existing videos', - to: "/shorts/", - }, - { - label: t("tools.vault.title"), - icon: "tabler:building-warehouse", - description: t("tools.vault.description"), - to: "/vault/", - enabled: me.value?.admin || me.value?.vault?.enabled, - }, - { - label: t("tools.admin.title"), - icon: "tabler:settings", - description: t("tools.admin.description"), - to: "/admin/", - enabled: me.value?.admin, - }, - ]); + const tools = computed(() => [ + { + label: t("tools.bmmUpload.title"), + icon: "tabler:upload", + description: t("tools.bmmUpload.description"), + to: "/upload/bmm/", + enabled: + me.value?.bmm && + (me.value.bmm.podcasts.length > 0 || me.value.bmm.admin), + }, + { + label: t("tools.transcription.title"), + icon: "tabler:edit", + description: t("tools.transcription.description"), + to: "/transcription/", + enabled: + me.value?.transcription && + (me.value.transcription.mediabanken || + me.value.transcription.admin), + }, + { + label: t("tools.export.title"), + icon: "tabler:file-export", + description: t("tools.export.description"), + to: "/export/", + enabled: + me.value?.admin || + (me.value?.export && + (me.value.export.destinations.length > 0 || + me.value.export.admin || + me.value.export.timedMetadata)), + }, + { + label: t("tools.vbExport.title"), + icon: "tabler:broadcast", + description: t("tools.vbExport.description"), + to: "/vb-export/", + // Shown when the user has access to any VB destination (or is on the page). + enabled: + me.value?.admin || + (me.value?.vbExport && + (me.value.vbExport.destinations.length > 0 || + me.value.vbExport.admin)) || + route.path.startsWith("/vb-export"), + }, + { + label: "Shorts generation", + icon: "tabler:device-mobile", + description: "Generate shorts from existing videos", + to: "/shorts/", + }, + { + label: t("tools.markers.title"), + icon: "tabler:bookmarks", + description: t("tools.markers.description"), + to: "/markers/", + }, + { + label: t("tools.vault.title"), + icon: "tabler:building-warehouse", + description: t("tools.vault.description"), + to: "/vault/", + enabled: me.value?.admin || me.value?.vault?.enabled, + }, + { + label: t("tools.admin.title"), + icon: "tabler:settings", + description: t("tools.admin.description"), + to: "/admin/", + enabled: me.value?.admin, + }, + ]); + const enabledTools = computed(() => + tools.value.filter((t) => t.enabled != false), + ); - const enabledTools = computed(() => - tools.value.filter((t) => t.enabled != false), - ); + const currentTool = computed(() => + tools.value.find((t) => route.path.startsWith(t.to)), + ); - const currentTool = computed(() => - tools.value.find((t) => route.path.startsWith(t.to)), - ); - - return { tools, enabledTools, currentTool }; + return { tools, enabledTools, currentTool }; } diff --git a/frontend/app/pages/markers/[id].vue b/frontend/app/pages/markers/[id].vue new file mode 100644 index 0000000..ac5062b --- /dev/null +++ b/frontend/app/pages/markers/[id].vue @@ -0,0 +1,342 @@ + + + diff --git a/frontend/app/pages/markers/index.vue b/frontend/app/pages/markers/index.vue new file mode 100644 index 0000000..c95f2f2 --- /dev/null +++ b/frontend/app/pages/markers/index.vue @@ -0,0 +1,56 @@ + + + diff --git a/frontend/app/utils/markers.ts b/frontend/app/utils/markers.ts new file mode 100644 index 0000000..9e25648 --- /dev/null +++ b/frontend/app/utils/markers.ts @@ -0,0 +1,60 @@ +// Markers tool — data model. +// +// NOTE: this tool is currently frontend-only. Markers are persisted to +// localStorage by `useMarkers`. The shapes below are intentionally close to +// what a future ConnectRPC `GetMarkers` / `SubmitMarkers` pair would return so +// the mock data layer can be swapped for `api.*` calls with minimal churn. + +export type MarkerType = + | "name-super" + | "bible-verse" + | "song" + | "chapter" + | "custom"; + +// Where a marker came from. "imported" markers originate from the third-party +// timing program (name-supers, bible-verse references, …); "manual" ones are +// created here. The flag is preserved through edits so the future backend can +// reconcile imported markers with their source. +export type MarkerSource = "imported" | "manual"; + +export type Marker = { + id: string; + type: MarkerType; + // Display text: the name, the verse reference, the song/chapter title. + label: string; + note?: string; + // In/out points in seconds (float, ms precision — matches transcription Word). + start: number; + end: number; + source: MarkerSource; +}; + +export type MarkerTypeMeta = { + value: MarkerType; + icon: string; + // Tailwind background class used on timeline blocks and list dots. + color: string; +}; + +// Single source of truth for the available marker types. Labels are resolved +// via i18n (`markers.types.`); see `markerTypeLabel`. +export const MARKER_TYPES: MarkerTypeMeta[] = [ + { value: "name-super", icon: "tabler:user", color: "bg-blue-500" }, + { value: "bible-verse", icon: "tabler:book-2", color: "bg-purple-500" }, + { value: "song", icon: "tabler:music", color: "bg-emerald-500" }, + { value: "chapter", icon: "tabler:bookmark", color: "bg-amber-500" }, + { value: "custom", icon: "tabler:tag", color: "bg-slate-500" }, +]; + +export function markerTypeMeta(type: MarkerType): MarkerTypeMeta { + return ( + MARKER_TYPES.find((m) => m.value === type) ?? + MARKER_TYPES[MARKER_TYPES.length - 1]! + ); +} + +// Sort by start time, then end time — stable order for the list and timeline. +export function sortMarkers(markers: Marker[]): Marker[] { + return [...markers].sort((a, b) => a.start - b.start || a.end - b.end); +} diff --git a/frontend/locales/en.json b/frontend/locales/en.json index cac00a3..5d70f2a 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -192,6 +192,10 @@ "vault": { "title": "Vault", "description": "Search Mediabanken and preview items" + }, + "markers": { + "title": "Markers", + "description": "Add, edit and remove markers on a video" } }, "vault": { @@ -218,5 +222,56 @@ "audio": "Audio", "image": "Image" } + }, + "markers": { + "save": "Save", + "saved": "Markers saved", + "savedMock": "Saved (mock — no backend yet)", + "savedLocally": "Saved locally", + "addAtPlayhead": "Add marker at playhead", + "addHint": "Press M to add a marker", + "previewUnavailable": "Preview unavailable", + "removed": "Removed \"{label}\"", + "undo": "Undo", + "index": { + "title": "Markers", + "description": "Open a Cantemo asset by its VX-ID to manage markers.", + "open": "Open", + "demoHint": "Frontend-only preview — markers are stored in your browser." + }, + "types": { + "name-super": "Name-super", + "bible-verse": "Bible verse", + "song": "Song", + "chapter": "Chapter", + "custom": "Custom" + }, + "source": { + "imported": "Imported", + "manual": "Manual" + }, + "timeline": { + "empty": "No markers yet. Add one at the playhead." + }, + "list": { + "title": "Markers", + "empty": "No markers match the current filter." + }, + "editor": { + "pageTitle": "Markers ·", + "title": "Edit marker", + "type": "Type", + "label": "Label", + "labelPlaceholder": "e.g. John Doe or John 3:16", + "note": "Note", + "notePlaceholder": "Optional note", + "in": "In", + "out": "Out", + "setToPlayhead": "Playhead", + "seekTo": "Seek to", + "duration": "Duration", + "remove": "Remove marker", + "noSelection": "Select a marker to edit, or add one at the playhead." + } } } diff --git a/frontend/locales/nb.json b/frontend/locales/nb.json index 9befe82..e637a18 100644 --- a/frontend/locales/nb.json +++ b/frontend/locales/nb.json @@ -192,6 +192,10 @@ "vault": { "title": "Vault", "description": "Søk i Mediabanken og forhåndsvis elementer" + }, + "markers": { + "title": "Markører", + "description": "Legg til, rediger og fjern markører på en video" } }, "vault": { @@ -218,5 +222,56 @@ "audio": "Lyd", "image": "Bilde" } + }, + "markers": { + "save": "Lagre", + "saved": "Markører lagret", + "savedMock": "Lagret (mock — ingen backend ennå)", + "savedLocally": "Lagret lokalt", + "addAtPlayhead": "Legg til markør ved spillehodet", + "addHint": "Trykk M for å legge til en markør", + "previewUnavailable": "Forhåndsvisning utilgjengelig", + "removed": "Fjernet «{label}»", + "undo": "Angre", + "index": { + "title": "Markører", + "description": "Åpne et Cantemo-element med VX-ID for å håndtere markører.", + "open": "Åpne", + "demoHint": "Kun frontend — markører lagres i nettleseren din." + }, + "types": { + "name-super": "Navneskilt", + "bible-verse": "Bibelvers", + "song": "Sang", + "chapter": "Kapittel", + "custom": "Egendefinert" + }, + "source": { + "imported": "Importert", + "manual": "Manuell" + }, + "timeline": { + "empty": "Ingen markører ennå. Legg til en ved spillehodet." + }, + "list": { + "title": "Markører", + "empty": "Ingen markører samsvarer med filteret." + }, + "editor": { + "pageTitle": "Markører ·", + "title": "Rediger markør", + "type": "Type", + "label": "Etikett", + "labelPlaceholder": "f.eks. Ola Nordmann eller Joh 3:16", + "note": "Notat", + "notePlaceholder": "Valgfritt notat", + "in": "Inn", + "out": "Ut", + "setToPlayhead": "Spillehode", + "seekTo": "Gå til", + "duration": "Varighet", + "remove": "Fjern markør", + "noSelection": "Velg en markør for å redigere, eller legg til en ved spillehodet." + } } } From 2f9df5a957c3e9239b6549f1aa419e234079ab59 Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 1 Jul 2026 10:08:50 +0200 Subject: [PATCH 02/28] chore(markers): remove marker track toggles --- frontend/app/pages/markers/[id].vue | 48 ++++++----------------------- 1 file changed, 9 insertions(+), 39 deletions(-) diff --git a/frontend/app/pages/markers/[id].vue b/frontend/app/pages/markers/[id].vue index ac5062b..e5216c3 100644 --- a/frontend/app/pages/markers/[id].vue +++ b/frontend/app/pages/markers/[id].vue @@ -1,6 +1,6 @@ From d9e86f801b9c7e096de336fd9b1162cbdcebe337 Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Wed, 1 Jul 2026 10:45:37 +0200 Subject: [PATCH 07/28] feat(markers): switch placement of editor and list --- frontend/app/pages/markers/[id].vue | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/frontend/app/pages/markers/[id].vue b/frontend/app/pages/markers/[id].vue index 72f5ad4..633142c 100644 --- a/frontend/app/pages/markers/[id].vue +++ b/frontend/app/pages/markers/[id].vue @@ -142,7 +142,7 @@ useEventListener(window, "keydown", (event: KeyboardEvent) => {