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>
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user