diff --git a/api/main.py b/api/main.py index 73b4be3..9ff3f01 100644 --- a/api/main.py +++ b/api/main.py @@ -544,6 +544,28 @@ async def get_records( } +class SetCoverUrlBody(BaseModel): + cover_url: str + + +@app.patch("/api/records/{record_id}/cover_url") +async def set_record_cover_url(record_id: int, body: SetCoverUrlBody): + if pool is None: + raise HTTPException(503, detail="Datenbank nicht verbunden") + url = body.cover_url.strip() + if not url: + raise HTTPException(422, detail="cover_url darf nicht leer sein") + async with pool.acquire() as conn: + row = await conn.fetchrow( + f"UPDATE {qualified_table} SET cover_url = $1, cover_image = NULL, cover_mime_type = NULL WHERE id = $2 RETURNING id, cover_url", + url, + record_id, + ) + if row is None: + raise HTTPException(404, detail=f"Eintrag mit ID {record_id} nicht gefunden") + return {"id": row["id"], "cover_url": row["cover_url"]} + + @app.patch("/api/records/{record_id}/seen") async def set_record_seen(record_id: int, body: SetSeenBody): """Einzelnen Eintrag als gesehen (seen=true) oder ungesehen (seen=false) markieren.""" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 82aae04..e1665b0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,12 +7,14 @@ import { fetchRecords, markAllSeen, setRecordSeen, + updateCoverUrl, } from './api' import type { FilterOptions, MediaRecord } from './types' import { isUnseen } from './utils/seen' import { useAutoRefresh } from './composables/useAutoRefresh' import { captureScroll, restoreScroll } from './utils/preserveScroll' import FilterBar from './components/FilterBar.vue' +import CoverUrlModal from './components/CoverUrlModal.vue' import { boundToIso, emptyDateTimeBound, type DateTimeBound } from './utils/datetime' import DataTable from './components/DataTable.vue' import MobileCards from './components/MobileCards.vue' @@ -39,6 +41,7 @@ let debounceTimer: ReturnType | null = null const isMobile = ref(false) const deletingId = ref(null) const togglingSeenId = ref(null) +const coverEditRecord = ref(null) const autoRefreshEnabled = ref(true) const autoRefreshPaused = computed( @@ -170,6 +173,21 @@ async function handleMarkAllSeen() { } } +async function handleCoverSave(recordId: number, url: string) { + error.value = null + try { + const result = await updateCoverUrl(recordId, url) + const record = records.value.find((r) => r.id === recordId) + if (record) { + record.cover_url = result.cover_url + record.cover_mime_type = null + } + coverEditRecord.value = null + } catch (e) { + error.value = e instanceof Error ? e.message : 'Cover-URL konnte nicht gespeichert werden' + } +} + async function handleDelete(id: number) { const row = records.value.find((r) => r.id === id) const label = row?.file_name_new || row?.file_name_original || `ID ${id}` @@ -285,6 +303,7 @@ onBeforeUnmount(() => { :toggling-seen-id="togglingSeenId" @delete="handleDelete" @toggle-seen="handleToggleSeen" + @edit-cover="(r) => (coverEditRecord = r)" />
@@ -294,11 +313,19 @@ onBeforeUnmount(() => { :toggling-seen-id="togglingSeenId" @delete="handleDelete" @toggle-seen="handleToggleSeen" + @edit-cover="(r) => (coverEditRecord = r)" />
+ + diff --git a/frontend/src/components/DataTable.vue b/frontend/src/components/DataTable.vue index 8751e61..d8703d4 100644 --- a/frontend/src/components/DataTable.vue +++ b/frontend/src/components/DataTable.vue @@ -26,8 +26,13 @@ const props = defineProps<{ const emit = defineEmits<{ delete: [id: number] 'toggle-seen': [record: MediaRecord] + 'edit-cover': [record: MediaRecord] }>() +function needsCover(record: MediaRecord): boolean { + return !record.cover_mime_type && (!record.cover_url || record.cover_url === 'N/A') +} + function onRowClick(record: MediaRecord, event: MouseEvent) { const target = event.target as HTMLElement if (target.closest('button, a, input, select')) return @@ -47,12 +52,21 @@ const columns: ColumnDef[] = [ accessorKey: 'cover_url', enableSorting: false, cell: ({ row }) => - h(CoverImage, { - url: row.original.cover_url, - id: row.original.id, - hasCoverInDb: Boolean(row.original.cover_mime_type), - size: 'table', - }), + h('div', { class: 'cover-cell' }, [ + h(CoverImage, { + url: row.original.cover_url, + id: row.original.id, + hasCoverInDb: Boolean(row.original.cover_mime_type), + size: 'table', + }), + needsCover(row.original) + ? h('button', { + class: 'cover-edit-btn', + title: 'Cover-URL eingeben', + onClick: (e: Event) => { e.stopPropagation(); emit('edit-cover', row.original) }, + }, '+') + : null, + ]), }, { id: 'object_type', @@ -364,6 +378,33 @@ td { padding-right: 0.35rem; } +.cover-cell { + position: relative; + display: inline-block; +} + +.cover-edit-btn { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.45); + border: 2px dashed var(--border); + border-radius: var(--radius); + color: var(--text-muted); + font-size: 1.4rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s; +} + +.cover-cell:hover .cover-edit-btn { + opacity: 1; +} + .th-type, .td-type { width: 88px; diff --git a/frontend/src/components/MobileCards.vue b/frontend/src/components/MobileCards.vue index 6eb15c5..0f6aaf5 100644 --- a/frontend/src/components/MobileCards.vue +++ b/frontend/src/components/MobileCards.vue @@ -17,8 +17,13 @@ const props = defineProps<{ const emit = defineEmits<{ delete: [id: number] 'toggle-seen': [record: MediaRecord] + 'edit-cover': [record: MediaRecord] }>() +function needsCover(record: MediaRecord): boolean { + return !record.cover_mime_type && (!record.cover_url || record.cover_url === 'N/A') +} + function onCardClick(record: MediaRecord, event: MouseEvent) { const target = event.target as HTMLElement if (target.closest('button, a, input, select, dl')) return @@ -74,7 +79,15 @@ const detailFields: { key: keyof MediaRecord; label: string }[] = [ @click="onCardClick(row, $event)" >
- +
+ + +
@@ -153,6 +166,27 @@ const detailFields: { key: keyof MediaRecord; label: string }[] = [ align-items: flex-start; } +.cover-wrap { + position: relative; + flex-shrink: 0; +} + +.cover-edit-btn { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.45); + border: 2px dashed var(--border); + border-radius: var(--radius); + color: var(--text-muted); + font-size: 1.4rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + .card__hero-text { flex: 1; min-width: 0; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 055de14..cc629af 100644 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -18,10 +18,13 @@ scp_remote() { echo "Übertrage Dateien …" scp_remote "api/main.py" "api/main.py" scp_remote "frontend/src/types.ts" "frontend/src/types.ts" +scp_remote "frontend/src/api.ts" "frontend/src/api.ts" scp_remote "frontend/src/utils/coverUrl.ts" "frontend/src/utils/coverUrl.ts" scp_remote "frontend/src/components/CoverImage.vue" "frontend/src/components/CoverImage.vue" scp_remote "frontend/src/components/DataTable.vue" "frontend/src/components/DataTable.vue" scp_remote "frontend/src/components/MobileCards.vue" "frontend/src/components/MobileCards.vue" +scp_remote "frontend/src/components/CoverUrlModal.vue" "frontend/src/components/CoverUrlModal.vue" +scp_remote "frontend/src/App.vue" "frontend/src/App.vue" echo "Baue Docker-Container neu …" ssh -o ControlPath="$SOCKET" "$HOST" "cd $REMOTE && docker compose up -d --build"