Add cover URL edit button and modal
Rows without a cover (cover_url NULL or N/A) show a + button.
Clicking it opens a modal to enter a URL which is saved via new
PATCH /api/records/{id}/cover_url endpoint. cover_image is cleared
so n8n picks it up on the next backfill run.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
50548d44c4
commit
7a7a997f15
+22
@@ -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")
|
@app.patch("/api/records/{record_id}/seen")
|
||||||
async def set_record_seen(record_id: int, body: SetSeenBody):
|
async def set_record_seen(record_id: int, body: SetSeenBody):
|
||||||
"""Einzelnen Eintrag als gesehen (seen=true) oder ungesehen (seen=false) markieren."""
|
"""Einzelnen Eintrag als gesehen (seen=true) oder ungesehen (seen=false) markieren."""
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import {
|
|||||||
fetchRecords,
|
fetchRecords,
|
||||||
markAllSeen,
|
markAllSeen,
|
||||||
setRecordSeen,
|
setRecordSeen,
|
||||||
|
updateCoverUrl,
|
||||||
} from './api'
|
} from './api'
|
||||||
import type { FilterOptions, MediaRecord } from './types'
|
import type { FilterOptions, MediaRecord } from './types'
|
||||||
import { isUnseen } from './utils/seen'
|
import { isUnseen } from './utils/seen'
|
||||||
import { useAutoRefresh } from './composables/useAutoRefresh'
|
import { useAutoRefresh } from './composables/useAutoRefresh'
|
||||||
import { captureScroll, restoreScroll } from './utils/preserveScroll'
|
import { captureScroll, restoreScroll } from './utils/preserveScroll'
|
||||||
import FilterBar from './components/FilterBar.vue'
|
import FilterBar from './components/FilterBar.vue'
|
||||||
|
import CoverUrlModal from './components/CoverUrlModal.vue'
|
||||||
import { boundToIso, emptyDateTimeBound, type DateTimeBound } from './utils/datetime'
|
import { boundToIso, emptyDateTimeBound, type DateTimeBound } from './utils/datetime'
|
||||||
import DataTable from './components/DataTable.vue'
|
import DataTable from './components/DataTable.vue'
|
||||||
import MobileCards from './components/MobileCards.vue'
|
import MobileCards from './components/MobileCards.vue'
|
||||||
@@ -39,6 +41,7 @@ let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
const isMobile = ref(false)
|
const isMobile = ref(false)
|
||||||
const deletingId = ref<number | null>(null)
|
const deletingId = ref<number | null>(null)
|
||||||
const togglingSeenId = ref<number | null>(null)
|
const togglingSeenId = ref<number | null>(null)
|
||||||
|
const coverEditRecord = ref<MediaRecord | null>(null)
|
||||||
|
|
||||||
const autoRefreshEnabled = ref(true)
|
const autoRefreshEnabled = ref(true)
|
||||||
const autoRefreshPaused = computed(
|
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) {
|
async function handleDelete(id: number) {
|
||||||
const row = records.value.find((r) => r.id === id)
|
const row = records.value.find((r) => r.id === id)
|
||||||
const label = row?.file_name_new || row?.file_name_original || `ID ${id}`
|
const label = row?.file_name_new || row?.file_name_original || `ID ${id}`
|
||||||
@@ -285,6 +303,7 @@ onBeforeUnmount(() => {
|
|||||||
:toggling-seen-id="togglingSeenId"
|
:toggling-seen-id="togglingSeenId"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@toggle-seen="handleToggleSeen"
|
@toggle-seen="handleToggleSeen"
|
||||||
|
@edit-cover="(r) => (coverEditRecord = r)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="isMobile" class="view-mobile">
|
<div v-show="isMobile" class="view-mobile">
|
||||||
@@ -294,11 +313,19 @@ onBeforeUnmount(() => {
|
|||||||
:toggling-seen-id="togglingSeenId"
|
:toggling-seen-id="togglingSeenId"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@toggle-seen="handleToggleSeen"
|
@toggle-seen="handleToggleSeen"
|
||||||
|
@edit-cover="(r) => (coverEditRecord = r)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CoverUrlModal
|
||||||
|
:open="coverEditRecord !== null"
|
||||||
|
:record-id="coverEditRecord?.id ?? 0"
|
||||||
|
@close="coverEditRecord = null"
|
||||||
|
@save="handleCoverSave"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ export async function markAllSeen(params: FetchParams = {}): Promise<{ marked: n
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateCoverUrl(
|
||||||
|
id: number,
|
||||||
|
coverUrl: string,
|
||||||
|
): Promise<{ id: number; cover_url: string }> {
|
||||||
|
const res = await fetch(`/api/records/${id}/cover_url`, {
|
||||||
|
...fetchOpts,
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ cover_url: coverUrl }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(await parseApiError(res))
|
||||||
|
return res.json() as Promise<{ id: number; cover_url: string }>
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRecord(id: number): Promise<void> {
|
export async function deleteRecord(id: number): Promise<void> {
|
||||||
const res = await fetch(`/api/records/${id}`, { ...fetchOpts, method: 'DELETE' })
|
const res = await fetch(`/api/records/${id}`, { ...fetchOpts, method: 'DELETE' })
|
||||||
if (!res.ok) throw new Error(await parseApiError(res))
|
if (!res.ok) throw new Error(await parseApiError(res))
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ recordId: number; open: boolean }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
save: [recordId: number, url: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const url = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(v) => {
|
||||||
|
if (v) { url.value = ''; error.value = '' }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
const trimmed = url.value.trim()
|
||||||
|
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
||||||
|
error.value = 'Bitte eine gültige URL eingeben (http:// oder https://)'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('save', props.recordId, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBackdrop(e: MouseEvent) {
|
||||||
|
if (e.target === e.currentTarget) emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="open" class="modal-backdrop" @click="onBackdrop">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true">
|
||||||
|
<h2 class="modal__title">Cover-URL festlegen</h2>
|
||||||
|
<p class="modal__hint">URL des Cover-Bildes eingeben. Das Bild wird sofort als Vorschau geladen und von n8n in die Datenbank übernommen.</p>
|
||||||
|
<input
|
||||||
|
v-model="url"
|
||||||
|
class="modal__input"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
autofocus
|
||||||
|
@keydown.enter="onSave"
|
||||||
|
@keydown.esc="emit('close')"
|
||||||
|
/>
|
||||||
|
<p v-if="error" class="modal__error">{{ error }}</p>
|
||||||
|
<div class="modal__actions">
|
||||||
|
<button class="btn btn--ghost" @click="emit('close')">Abbrechen</button>
|
||||||
|
<button class="btn btn--primary" @click="onSave">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: min(480px, 90vw);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--danger, #e05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.45rem 1rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--ghost {
|
||||||
|
background: none;
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn--primary:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -26,8 +26,13 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
delete: [id: number]
|
delete: [id: number]
|
||||||
'toggle-seen': [record: MediaRecord]
|
'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) {
|
function onRowClick(record: MediaRecord, event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (target.closest('button, a, input, select')) return
|
if (target.closest('button, a, input, select')) return
|
||||||
@@ -47,12 +52,21 @@ const columns: ColumnDef<MediaRecord>[] = [
|
|||||||
accessorKey: 'cover_url',
|
accessorKey: 'cover_url',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: ({ row }) =>
|
cell: ({ row }) =>
|
||||||
h(CoverImage, {
|
h('div', { class: 'cover-cell' }, [
|
||||||
url: row.original.cover_url,
|
h(CoverImage, {
|
||||||
id: row.original.id,
|
url: row.original.cover_url,
|
||||||
hasCoverInDb: Boolean(row.original.cover_mime_type),
|
id: row.original.id,
|
||||||
size: 'table',
|
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',
|
id: 'object_type',
|
||||||
@@ -364,6 +378,33 @@ td {
|
|||||||
padding-right: 0.35rem;
|
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,
|
.th-type,
|
||||||
.td-type {
|
.td-type {
|
||||||
width: 88px;
|
width: 88px;
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
delete: [id: number]
|
delete: [id: number]
|
||||||
'toggle-seen': [record: MediaRecord]
|
'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) {
|
function onCardClick(record: MediaRecord, event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (target.closest('button, a, input, select, dl')) return
|
if (target.closest('button, a, input, select, dl')) return
|
||||||
@@ -74,7 +79,15 @@ const detailFields: { key: keyof MediaRecord; label: string }[] = [
|
|||||||
@click="onCardClick(row, $event)"
|
@click="onCardClick(row, $event)"
|
||||||
>
|
>
|
||||||
<div class="card__hero">
|
<div class="card__hero">
|
||||||
<CoverImage :url="row.cover_url" :id="row.id" :has-cover-in-db="Boolean(row.cover_mime_type)" size="card" />
|
<div class="cover-wrap">
|
||||||
|
<CoverImage :url="row.cover_url" :id="row.id" :has-cover-in-db="Boolean(row.cover_mime_type)" size="card" />
|
||||||
|
<button
|
||||||
|
v-if="needsCover(row)"
|
||||||
|
class="cover-edit-btn"
|
||||||
|
title="Cover-URL eingeben"
|
||||||
|
@click.stop="emit('edit-cover', row)"
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
<div class="card__hero-text">
|
<div class="card__hero-text">
|
||||||
<header class="card__header">
|
<header class="card__header">
|
||||||
<div class="card__meta">
|
<div class="card__meta">
|
||||||
@@ -153,6 +166,27 @@ const detailFields: { key: keyof MediaRecord; label: string }[] = [
|
|||||||
align-items: flex-start;
|
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 {
|
.card__hero-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ scp_remote() {
|
|||||||
echo "Übertrage Dateien …"
|
echo "Übertrage Dateien …"
|
||||||
scp_remote "api/main.py" "api/main.py"
|
scp_remote "api/main.py" "api/main.py"
|
||||||
scp_remote "frontend/src/types.ts" "frontend/src/types.ts"
|
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/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/CoverImage.vue" "frontend/src/components/CoverImage.vue"
|
||||||
scp_remote "frontend/src/components/DataTable.vue" "frontend/src/components/DataTable.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/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 …"
|
echo "Baue Docker-Container neu …"
|
||||||
ssh -o ControlPath="$SOCKET" "$HOST" "cd $REMOTE && docker compose up -d --build"
|
ssh -o ControlPath="$SOCKET" "$HOST" "cd $REMOTE && docker compose up -d --build"
|
||||||
|
|||||||
Reference in New Issue
Block a user