Files
filebot-media-browser/frontend/src/components/MobileCards.vue
T
HousemannandClaude Sonnet 4.6 3f253ffe81 Initial commit: Filebot Media Browser
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>
2026-07-11 05:38:37 +02:00

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>