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>
292 lines
6.9 KiB
Vue
292 lines
6.9 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import type { MediaRecord } from '../types'
|
||
import { formatDisplay } from '../utils/names'
|
||
import { isUnseen } from '../utils/seen'
|
||
import CoverImage from './CoverImage.vue'
|
||
import NamePair from './NamePair.vue'
|
||
import ObjectTypeBadge from './ObjectTypeBadge.vue'
|
||
import DeleteButton from './DeleteButton.vue'
|
||
|
||
const props = defineProps<{
|
||
data: MediaRecord[]
|
||
deletingId?: number | null
|
||
togglingSeenId?: number | null
|
||
}>()
|
||
|
||
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
|
||
emit('toggle-seen', record)
|
||
}
|
||
|
||
const expandedId = ref<number | null>(null)
|
||
|
||
function sortByNewest(list: MediaRecord[]): MediaRecord[] {
|
||
return [...list].sort((a, b) => {
|
||
const ta = a.created_at ? new Date(a.created_at).getTime() : 0
|
||
const tb = b.created_at ? new Date(b.created_at).getTime() : 0
|
||
if (tb !== ta) return tb - ta
|
||
return (b.id ?? 0) - (a.id ?? 0)
|
||
})
|
||
}
|
||
|
||
const filtered = computed(() => sortByNewest(props.data))
|
||
|
||
function toggle(id: number) {
|
||
expandedId.value = expandedId.value === id ? null : id
|
||
}
|
||
|
||
const detailFields: { key: keyof MediaRecord; label: string }[] = [
|
||
{ key: 'movie_votes', label: 'Votes' },
|
||
{ key: 'video_compression_format', label: 'Kompression' },
|
||
{ key: 'video_codec_library', label: 'Video-Codec' },
|
||
{ key: 'audio_codec', label: 'Audio-Codec' },
|
||
{ key: 'audio_codec_profile', label: 'Audio-Profil' },
|
||
{ key: 'audio_channel_layout', label: 'Layout' },
|
||
{ key: 'video_width', label: 'Breite' },
|
||
{ key: 'video_height', label: 'Höhe' },
|
||
{ key: 'video_bitdepth', label: 'Bit-Tiefe' },
|
||
{ key: 'overall_bitrate', label: 'Gesamt-Bitrate' },
|
||
{ key: 'video_bitrate', label: 'Video-Bitrate' },
|
||
{ key: 'audio_bitrate', label: 'Audio-Bitrate' },
|
||
{ key: 'created_at', label: 'Erstellt' },
|
||
]
|
||
</script>
|
||
|
||
<template>
|
||
<div class="mobile">
|
||
<div class="mobile__list">
|
||
<article
|
||
v-for="row in filtered"
|
||
:key="row.id"
|
||
class="card card-clickable"
|
||
:title="isUnseen(row) ? 'Tippen: als gesehen markieren' : 'Tippen: als ungesehen markieren'"
|
||
:class="{
|
||
'card-unseen': isUnseen(row),
|
||
'card-busy': togglingSeenId === row.id,
|
||
}"
|
||
@click="onCardClick(row, $event)"
|
||
>
|
||
<div class="card__hero">
|
||
<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">
|
||
<header class="card__header">
|
||
<div class="card__meta">
|
||
<ObjectTypeBadge :value="row.object_type" />
|
||
<span v-if="row.standard_video_format" class="badge badge-format">
|
||
{{ row.standard_video_format }}
|
||
</span>
|
||
<span v-if="row.movie_rating" class="card__rating">{{ row.movie_rating }}</span>
|
||
</div>
|
||
<div class="card__header-end">
|
||
<span class="card__resolution">{{ formatDisplay(row.video_resolution) }}</span>
|
||
<DeleteButton
|
||
:loading="deletingId === row.id"
|
||
@click="emit('delete', row.id)"
|
||
/>
|
||
</div>
|
||
</header>
|
||
|
||
<NamePair
|
||
:original="row.file_name_original"
|
||
:renamed="row.file_name_new"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card__chips">
|
||
<span>{{ formatDisplay(row.video_codec_library) }}</span>
|
||
<span>{{ formatDisplay(row.audio_codec) }}</span>
|
||
<span>{{ formatDisplay(row.container_format) }}</span>
|
||
</div>
|
||
|
||
<button type="button" class="card__toggle" @click="toggle(row.id)">
|
||
{{ expandedId === row.id ? 'Weniger' : 'Technische Details' }}
|
||
</button>
|
||
|
||
<dl v-if="expandedId === row.id" class="card__details">
|
||
<template v-for="field in detailFields" :key="field.key">
|
||
<dt>{{ field.label }}</dt>
|
||
<dd>{{ formatDisplay(row[field.key]) }}</dd>
|
||
</template>
|
||
</dl>
|
||
</article>
|
||
|
||
<p v-if="filtered.length === 0" class="mobile__empty">Keine Einträge gefunden.</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.mobile {
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-height: calc(100vh - 248px);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.mobile__list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1rem;
|
||
}
|
||
|
||
.card {
|
||
background: var(--bg-elevated);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 1rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.85rem;
|
||
}
|
||
|
||
.card__hero {
|
||
display: flex;
|
||
gap: 0.85rem;
|
||
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;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.65rem;
|
||
}
|
||
|
||
.card-clickable {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.card-busy {
|
||
opacity: 0.65;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.card__header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.card__header-end {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.card__meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.card__rating {
|
||
font-weight: 700;
|
||
color: var(--warning);
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.card__resolution {
|
||
font-size: 0.8rem;
|
||
color: var(--text-muted);
|
||
font-family: var(--mono);
|
||
}
|
||
|
||
.card__chips {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.4rem;
|
||
}
|
||
|
||
.card__chips span {
|
||
font-size: 0.75rem;
|
||
padding: 0.2rem 0.5rem;
|
||
background: var(--bg-card);
|
||
border-radius: 6px;
|
||
color: var(--text-muted);
|
||
font-family: var(--mono);
|
||
}
|
||
|
||
.card__toggle {
|
||
background: none;
|
||
border: none;
|
||
color: var(--accent-hover);
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
text-align: left;
|
||
}
|
||
|
||
.card__details {
|
||
display: grid;
|
||
grid-template-columns: auto 1fr;
|
||
gap: 0.35rem 1rem;
|
||
margin: 0;
|
||
padding-top: 0.5rem;
|
||
border-top: 1px solid var(--border);
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.card__details dt {
|
||
color: var(--text-muted);
|
||
margin: 0;
|
||
}
|
||
|
||
.card__details dd {
|
||
margin: 0;
|
||
font-family: var(--mono);
|
||
word-break: break-all;
|
||
}
|
||
|
||
.mobile__empty {
|
||
text-align: center;
|
||
color: var(--text-muted);
|
||
padding: 2rem;
|
||
}
|
||
</style>
|