Vue 3 + FastAPI Webapp zur Anzeige von Filebot/n8n-Medienmetadaten aus PostgreSQL. Enthält Frontend, API, Nginx-Config, SQL-Migrationen und Docker-Compose-Setup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
258 lines
6.0 KiB
Vue
258 lines
6.0 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]
|
|
}>()
|
|
|
|
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">
|
|
<CoverImage :url="row.cover_url" size="card" />
|
|
<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;
|
|
}
|
|
|
|
.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>
|