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:
Housemann
2026-07-11 05:38:37 +02:00
co-authored by Claude Sonnet 4.6
commit 3f253ffe81
45 changed files with 4791 additions and 0 deletions
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { coverImageSrc } from '../utils/coverUrl'
const props = defineProps<{
url: string | null | undefined
size?: 'table' | 'card'
}>()
const failed = ref(false)
const src = computed(() => coverImageSrc(props.url))
watch(
() => props.url,
() => {
failed.value = false
}
)
</script>
<template>
<div class="cover" :class="`cover--${size ?? 'table'}`">
<img
v-if="src && !failed"
:key="src"
:src="src"
alt=""
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
@error="failed = true"
/>
<div v-else class="cover__placeholder" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="5" width="18" height="14" rx="2" />
<circle cx="8.5" cy="10" r="1.5" fill="currentColor" stroke="none" />
<path d="m21 16-5.5-5.5a2 2 0 0 0-2.8 0L7 16" />
</svg>
</div>
</div>
</template>
<style scoped>
.cover {
flex-shrink: 0;
}
.cover--table img,
.cover--table .cover__placeholder {
width: 76px;
height: 114px;
}
.cover--card img,
.cover--card .cover__placeholder {
width: 64px;
height: 96px;
}
.cover img {
display: block;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elevated);
}
.cover__placeholder {
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
border: 1px dashed var(--border);
background: var(--bg-elevated);
color: var(--text-muted);
}
.cover__placeholder svg {
width: 1.75rem;
height: 1.75rem;
opacity: 0.5;
}
</style>
+415
View File
@@ -0,0 +1,415 @@
<script setup lang="ts">
import {
FlexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
useVueTable,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
} from '@tanstack/vue-table'
import { computed, h, ref } from 'vue'
import type { MediaRecord } from '../types'
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 onRowClick(record: MediaRecord, event: MouseEvent) {
const target = event.target as HTMLElement
if (target.closest('button, a, input, select')) return
emit('toggle-seen', record)
}
const defaultSorting: SortingState = [{ id: 'created_at', desc: true }]
const sorting = ref<SortingState>([...defaultSorting])
const columnFilters = ref<ColumnFiltersState>([])
const globalFilter = ref('')
const columns: ColumnDef<MediaRecord>[] = [
{
id: 'cover',
header: 'Cover',
accessorKey: 'cover_url',
enableSorting: false,
cell: ({ row }) =>
h(CoverImage, {
url: row.original.cover_url,
size: 'table',
}),
},
{
id: 'object_type',
header: 'Typ',
accessorKey: 'object_type',
enableSorting: false,
cell: ({ row }) =>
h(ObjectTypeBadge, {
value: row.original.object_type,
}),
},
{
id: 'names',
header: 'Dateinamen (Original → Neu)',
accessorFn: (row) => `${row.file_name_original} ${row.file_name_new}`,
cell: ({ row }) =>
h(NamePair, {
original: row.original.file_name_original,
renamed: row.original.file_name_new,
}),
enableSorting: false,
},
{
accessorKey: 'movie_rating',
header: 'Rating',
cell: (info) => {
const v = info.getValue()
if (!v) return '—'
return h('span', { class: 'rating' }, String(v))
},
},
{ accessorKey: 'movie_votes', header: 'Votes' },
{ accessorKey: 'standard_video_format', header: 'Format' },
{ accessorKey: 'video_resolution', header: 'Auflösung' },
{ accessorKey: 'video_compression_format', header: 'Kompression' },
{ accessorKey: 'video_codec_library', header: 'Codec' },
{ accessorKey: 'audio_codec', header: 'Audio' },
{ accessorKey: 'audio_channel_layout', header: 'Kanäle' },
{ accessorKey: 'container_format', header: 'Container' },
{ accessorKey: 'overall_bitrate', header: 'Gesamt' },
{ accessorKey: 'video_bitrate', header: 'Video' },
{ accessorKey: 'audio_bitrate', header: 'Audio BR' },
{
accessorKey: 'created_at',
header: 'Erstellt',
sortingFn: (rowA, rowB) => {
const ta = rowA.original.created_at
? new Date(rowA.original.created_at).getTime()
: 0
const tb = rowB.original.created_at
? new Date(rowB.original.created_at).getTime()
: 0
if (ta !== tb) return ta - tb
return (rowA.original.id ?? 0) - (rowB.original.id ?? 0)
},
cell: (info) => {
const v = info.getValue() as string | null
if (!v) return '—'
try {
return new Date(v).toLocaleString('de-DE')
} catch {
return v
}
},
},
{
id: 'actions',
header: '',
enableSorting: false,
cell: ({ row }) =>
h(
'div',
{ onClick: (e: Event) => e.stopPropagation() },
[
h(DeleteButton, {
loading: props.deletingId === row.original.id,
onClick: () => emit('delete', row.original.id),
}),
]
),
},
]
function isFixedColumn(columnId: string): boolean {
return columnId === 'cover' || columnId === 'names' || columnId === 'object_type'
}
function isShrinkColumn(columnId: string): boolean {
return !isFixedColumn(columnId)
}
const table = useVueTable({
get data() {
return props.data
},
getRowId: (row) => String(row.id),
columns,
state: {
get sorting() {
return sorting.value
},
get columnFilters() {
return columnFilters.value
},
get globalFilter() {
return globalFilter.value
},
},
onSortingChange: (updater) => {
sorting.value = typeof updater === 'function' ? updater(sorting.value) : updater
},
onColumnFiltersChange: (updater) => {
columnFilters.value =
typeof updater === 'function' ? updater(columnFilters.value) : updater
},
onGlobalFilterChange: (updater) => {
globalFilter.value =
typeof updater === 'function' ? updater(globalFilter.value) : updater
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
globalFilterFn: (row, _columnId, filterValue) => {
const q = String(filterValue).toLowerCase()
if (!q) return true
const r = row.original
const haystack = [
r.file_name_original,
r.file_name_new,
r.standard_video_format,
r.video_codec_library,
r.audio_codec,
r.container_format,
r.object_type,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(q)
},
})
const displayRows = computed(() => table.getRowModel().rows)
defineExpose({ setGlobalFilter: (v: string) => { globalFilter.value = v } })
</script>
<template>
<div class="table-wrap">
<div class="table-toolbar">
<input
v-model="globalFilter"
type="search"
class="table-toolbar__search"
placeholder="Schnellfilter in geladenen Daten…"
/>
<span class="table-toolbar__count">{{ displayRows.length }} Zeilen</span>
</div>
<div class="table-scroll">
<table class="media-table">
<thead>
<tr v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<th
v-for="header in headerGroup.headers"
:key="header.id"
:class="{
sortable: header.column.getCanSort(),
'th-cover': header.column.id === 'cover',
'th-type': header.column.id === 'object_type',
'th-names': header.column.id === 'names',
'th-shrink': isShrinkColumn(header.column.id),
}"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span v-if="header.column.getIsSorted()" class="sort-icon">
{{ header.column.getIsSorted() === 'asc' ? '' : '' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in displayRows"
:key="row.original.id"
class="row-clickable"
:title="isUnseen(row.original) ? 'Tippen: als gesehen markieren' : 'Tippen: als ungesehen markieren'"
:class="{
'row-unseen': isUnseen(row.original),
'row-busy': togglingSeenId === row.original.id,
}"
@click="onRowClick(row.original, $event)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="{
'td-cover': cell.column.id === 'cover',
'td-type': cell.column.id === 'object_type',
'td-names': cell.column.id === 'names',
'td-actions': cell.column.id === 'actions',
'td-shrink': !isFixedColumn(cell.column.id),
}"
>
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.table-wrap {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.table-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
}
.table-toolbar__search {
flex: 1;
min-width: 200px;
}
.table-toolbar__count {
font-size: 0.8rem;
color: var(--text-muted);
margin-left: auto;
}
.table-scroll {
overflow: auto;
max-height: calc(100vh - 248px);
overflow-anchor: auto;
}
.media-table {
width: max-content;
min-width: 100%;
table-layout: auto;
border-collapse: collapse;
font-size: 0.85rem;
}
thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--bg-card);
}
th {
text-align: left;
padding: 0.65rem 0.5rem;
font-weight: 600;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
border-bottom: 1px solid var(--border-strong);
}
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
color: var(--text);
}
.sort-icon {
margin-left: 0.25rem;
color: var(--accent);
}
td {
padding: 0.65rem 0.5rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
/* Cover: feste Breite */
.th-cover,
.td-cover {
width: 88px;
padding-left: 0.5rem;
padding-right: 0.35rem;
}
.th-type,
.td-type {
width: 88px;
text-align: center;
vertical-align: middle;
}
/* Dateinamen: feste Breite (einzige „reservierte“ Spalte) */
.th-names,
.td-names {
width: 360px;
min-width: 280px;
max-width: 420px;
vertical-align: top;
}
/* Metadaten: automatische Breite nach Inhalt */
.th-shrink,
.td-shrink {
white-space: nowrap;
padding-left: 0.45rem;
padding-right: 0.45rem;
}
.td-actions {
text-align: center;
vertical-align: middle;
}
tbody tr.row-clickable {
cursor: pointer;
}
tbody tr:hover {
background: var(--bg-hover);
}
tbody tr.row-unseen:hover {
background: rgba(99, 102, 241, 0.22);
}
tbody tr.row-busy {
opacity: 0.65;
pointer-events: none;
}
.rating {
font-weight: 700;
color: var(--warning);
}
</style>
@@ -0,0 +1,447 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { DateTimeBound } from '../utils/datetime'
import {
formatBoundLabel,
getCalendarDays,
getMonthLabel,
getWeekdayLabels,
HOURS,
MINUTES,
toIsoDate,
} from '../utils/datetime'
const props = defineProps<{
from: DateTimeBound
to: DateTimeBound
compact?: boolean
}>()
const emit = defineEmits<{
'update:from': [value: DateTimeBound]
'update:to': [value: DateTimeBound]
}>()
const openPicker = ref<'from' | 'to' | null>(null)
const viewYear = ref(new Date().getFullYear())
const viewMonth = ref(new Date().getMonth())
const rootEl = ref<HTMLElement | null>(null)
const weekdays = getWeekdayLabels()
const calendarDays = computed(() => getCalendarDays(viewYear.value, viewMonth.value))
function updateBound(side: 'from' | 'to', patch: Partial<DateTimeBound>) {
const current = side === 'from' ? props.from : props.to
const next = { ...current, ...patch }
if (side === 'from') emit('update:from', next)
else emit('update:to', next)
}
function openCalendar(side: 'from' | 'to') {
const bound = side === 'from' ? props.from : props.to
if (bound.date) {
const [y, m] = bound.date.split('-').map(Number)
viewYear.value = y
viewMonth.value = m - 1
} else {
const now = new Date()
viewYear.value = now.getFullYear()
viewMonth.value = now.getMonth()
}
openPicker.value = openPicker.value === side ? null : side
}
function pickDate(side: 'from' | 'to', date: string) {
updateBound(side, { date })
openPicker.value = null
}
function prevMonth() {
if (viewMonth.value === 0) {
viewMonth.value = 11
viewYear.value -= 1
} else {
viewMonth.value -= 1
}
}
function nextMonth() {
if (viewMonth.value === 11) {
viewMonth.value = 0
viewYear.value += 1
} else {
viewMonth.value += 1
}
}
function clearBound(side: 'from' | 'to') {
emit(`update:${side}`, { date: '', hour: '00', minute: '00' })
if (openPicker.value === side) openPicker.value = null
}
function onDocumentClick(e: MouseEvent) {
if (!openPicker.value || !rootEl.value) return
if (!rootEl.value.contains(e.target as Node)) openPicker.value = null
}
onMounted(() => document.addEventListener('click', onDocumentClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
</script>
<template>
<div ref="rootEl" class="dt-range" :class="{ 'dt-range--compact': compact }">
<div
v-for="side in (['from', 'to'] as const)"
:key="side"
class="dt-range__col"
>
<span class="dt-range__label">{{ side === 'from' ? 'Von' : 'Bis' }}</span>
<div class="dt-range__controls">
<div class="dt-range__date-wrap">
<button
type="button"
class="dt-range__date-btn"
:class="{ 'dt-range__date-btn--active': (side === 'from' ? from : to).date }"
@click.stop="openCalendar(side)"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
<span>{{ formatBoundLabel(side === 'from' ? from : to) }}</span>
</button>
<div
v-if="openPicker === side"
class="dt-calendar"
@click.stop
>
<div class="dt-calendar__head">
<button type="button" class="dt-calendar__nav" aria-label="Vorheriger Monat" @click="prevMonth"></button>
<span>{{ getMonthLabel(viewMonth) }} {{ viewYear }}</span>
<button type="button" class="dt-calendar__nav" aria-label="Nächster Monat" @click="nextMonth"></button>
</div>
<div class="dt-calendar__weekdays">
<span v-for="wd in weekdays" :key="wd">{{ wd }}</span>
</div>
<div class="dt-calendar__days">
<button
v-for="(cell, i) in calendarDays"
:key="`${cell.date}-${i}`"
type="button"
class="dt-calendar__day"
:class="{
'dt-calendar__day--muted': !cell.currentMonth,
'dt-calendar__day--selected': (side === 'from' ? from : to).date === cell.date,
'dt-calendar__day--today': cell.date === toIsoDate(new Date()),
}"
@click="pickDate(side, cell.date)"
>
{{ cell.day }}
</button>
</div>
</div>
</div>
<div class="dt-range__time">
<select
:value="side === 'from' ? from.hour : to.hour"
class="dt-range__time-select"
:disabled="!(side === 'from' ? from.date : to.date)"
@change="updateBound(side, { hour: ($event.target as HTMLSelectElement).value })"
>
<option v-for="h in HOURS" :key="h" :value="h">{{ h }}</option>
</select>
<span class="dt-range__colon">:</span>
<select
:value="side === 'from' ? from.minute : to.minute"
class="dt-range__time-select"
:disabled="!(side === 'from' ? from.date : to.date)"
@change="updateBound(side, { minute: ($event.target as HTMLSelectElement).value })"
>
<option v-for="m in MINUTES" :key="m" :value="m">{{ m }}</option>
</select>
</div>
<button
v-if="(side === 'from' ? from : to).date"
type="button"
class="dt-range__clear"
title="Zurücksetzen"
@click="clearBound(side)"
>
×
</button>
</div>
</div>
</div>
</template>
<style scoped>
.dt-range {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 700px) {
.dt-range {
grid-template-columns: 1fr;
}
}
.dt-range__label {
display: block;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 0.45rem;
}
.dt-range__controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.dt-range__date-wrap {
position: relative;
flex: 1;
min-width: 140px;
}
.dt-range__date-btn {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text-muted);
font-size: 0.85rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.dt-range__date-btn svg {
width: 1rem;
height: 1rem;
flex-shrink: 0;
}
.dt-range__date-btn--active,
.dt-range__date-btn:hover {
color: var(--text);
border-color: var(--border-strong);
background: var(--bg-hover);
}
.dt-range__date-btn--active {
border-color: var(--accent);
}
.dt-calendar {
position: absolute;
top: calc(100% + 0.35rem);
left: 0;
z-index: 50;
width: min(100%, 280px);
padding: 0.75rem;
background: var(--bg-card);
border: 1px solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.dt-calendar__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.65rem;
font-weight: 600;
font-size: 0.9rem;
}
.dt-calendar__nav {
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--radius-sm);
background: var(--bg-elevated);
color: var(--text);
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
}
.dt-calendar__nav:hover {
background: var(--bg-hover);
}
.dt-calendar__weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 0.35rem;
font-size: 0.65rem;
font-weight: 700;
color: var(--text-muted);
text-align: center;
}
.dt-calendar__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.dt-calendar__day {
aspect-ratio: 1;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text);
font-size: 0.8rem;
cursor: pointer;
}
.dt-calendar__day:hover {
background: var(--bg-hover);
}
.dt-calendar__day--muted {
color: var(--text-muted);
opacity: 0.45;
}
.dt-calendar__day--today {
border: 1px solid var(--accent);
}
.dt-calendar__day--selected {
background: var(--accent);
color: #fff;
font-weight: 700;
}
.dt-range__time {
display: flex;
align-items: center;
gap: 0.2rem;
}
.dt-range__time-select {
width: 3.4rem;
padding: 0.5rem 0.35rem;
text-align: center;
font-family: var(--mono);
font-size: 0.85rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b93a7' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.25rem center;
padding-right: 1.1rem;
}
.dt-range__time-select:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dt-range__colon {
font-family: var(--mono);
font-weight: 700;
color: var(--text-muted);
}
.dt-range__clear {
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
}
.dt-range__clear:hover {
color: var(--danger);
border-color: rgba(248, 113, 113, 0.4);
}
/* Kompakt: Von/Bis in einer Zeile, weniger Höhe */
.dt-range--compact {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.65rem;
flex: 1;
min-width: 0;
}
.dt-range--compact .dt-range__col {
display: flex;
align-items: center;
gap: 0.3rem;
flex: 0 1 auto;
}
.dt-range--compact .dt-range__label {
margin-bottom: 0;
min-width: 1.75rem;
font-size: 0.62rem;
padding-top: 0;
}
.dt-range--compact .dt-range__controls {
gap: 0.28rem;
}
.dt-range--compact .dt-range__date-wrap {
min-width: 6.5rem;
flex: 0 1 8.5rem;
}
.dt-range--compact .dt-range__date-btn {
padding: 0.22rem 0.4rem;
font-size: 0.72rem;
gap: 0.3rem;
}
.dt-range--compact .dt-range__date-btn svg {
width: 0.85rem;
height: 0.85rem;
}
.dt-range--compact .dt-range__date-btn span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dt-range--compact .dt-range__time-select {
width: 2.85rem;
padding: 0.22rem 0.2rem;
padding-right: 0.95rem;
font-size: 0.72rem;
}
.dt-range--compact .dt-range__clear {
width: 1.5rem;
height: 1.5rem;
font-size: 1rem;
}
.dt-range--compact .dt-calendar {
width: min(100vw - 2rem, 260px);
padding: 0.5rem;
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script setup lang="ts">
defineProps<{
loading?: boolean
}>()
defineEmits<{
click: []
}>()
</script>
<template>
<button
type="button"
class="btn-delete"
:disabled="loading"
title="Eintrag löschen"
aria-label="Eintrag löschen"
@click="$emit('click')"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M3 6h18M8 6V4h8v2M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
<path d="M10 11v6M14 11v6" />
</svg>
</button>
</template>
<style scoped>
.btn-delete {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
padding: 0;
border: 1px solid rgba(248, 113, 113, 0.35);
border-radius: var(--radius-sm);
background: rgba(248, 113, 113, 0.1);
color: var(--danger);
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
transform 0.1s;
flex-shrink: 0;
}
.btn-delete svg {
width: 1.1rem;
height: 1.1rem;
}
.btn-delete:hover:not(:disabled) {
background: rgba(248, 113, 113, 0.22);
border-color: var(--danger);
}
.btn-delete:active:not(:disabled) {
transform: scale(0.95);
}
.btn-delete:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>
+391
View File
@@ -0,0 +1,391 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { FilterOptions } from '../types'
import type { DateTimeBound } from '../utils/datetime'
import { objectTypeFilterLabel } from '../utils/objectType'
import DateTimeRangeFilter from './DateTimeRangeFilter.vue'
const props = defineProps<{
search: string
format: string
codec: string
container: string
objectType: string
dateFrom: DateTimeBound
dateTo: DateTimeBound
unseenOnly: boolean
options: FilterOptions | null
loading: boolean
total: number
unseenTotal: number
}>()
const emit = defineEmits<{
'update:search': [value: string]
'update:format': [value: string]
'update:codec': [value: string]
'update:container': [value: string]
'update:objectType': [value: string]
'update:dateFrom': [value: DateTimeBound]
'update:dateTo': [value: DateTimeBound]
'update:unseenOnly': [value: boolean]
refresh: []
reset: []
'mark-all-seen': []
}>()
const objectTypeOptions = computed(() => {
const fromDb = props.options?.object_types ?? []
if (fromDb.length) return fromDb
return ['movie', 'episode']
})
function toggle(
field: 'format' | 'codec' | 'container' | 'objectType',
current: string,
value: string
) {
const next = current === value ? '' : value
if (field === 'format') emit('update:format', next)
else if (field === 'codec') emit('update:codec', next)
else if (field === 'objectType') emit('update:objectType', next)
else emit('update:container', next)
}
</script>
<template>
<section class="filters">
<div class="filters__search">
<svg class="filters__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
:value="search"
type="search"
class="filters__search-input"
placeholder="Suche in Original- und neuem Dateinamen…"
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
/>
</div>
<div class="filters__chips">
<div class="chip-group">
<span class="chip-group__label">Typ</span>
<div class="chip-group__btns">
<button
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': !objectType }"
@click="emit('update:objectType', '')"
>
Alle
</button>
<button
v-for="t in objectTypeOptions"
:key="t"
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': objectType === t }"
@click="toggle('objectType', objectType, t)"
>
{{ objectTypeFilterLabel(t) }}
</button>
</div>
</div>
<div class="chip-group">
<span class="chip-group__label">Format</span>
<div class="chip-group__btns">
<button
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': !format }"
@click="emit('update:format', '')"
>
Alle
</button>
<button
v-for="f in options?.formats ?? []"
:key="f"
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': format === f }"
@click="toggle('format', format, f)"
>
{{ f }}
</button>
</div>
</div>
<div class="chip-group">
<span class="chip-group__label">Codec</span>
<div class="chip-group__btns">
<button
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': !codec }"
@click="emit('update:codec', '')"
>
Alle
</button>
<button
v-for="c in options?.codecs ?? []"
:key="c"
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': codec === c }"
@click="toggle('codec', codec, c)"
>
{{ c }}
</button>
</div>
</div>
<div class="chip-group">
<span class="chip-group__label">Container</span>
<div class="chip-group__btns">
<button
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': !container }"
@click="emit('update:container', '')"
>
Alle
</button>
<button
v-for="c in options?.containers ?? []"
:key="c"
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': container === c }"
@click="toggle('container', container, c)"
>
{{ c }}
</button>
</div>
</div>
</div>
<div class="filters__bottom">
<div class="filters__bottom-left">
<span class="chip-group__label chip-group__label--inline">Zeitraum</span>
<DateTimeRangeFilter
compact
:from="dateFrom"
:to="dateTo"
@update:from="emit('update:dateFrom', $event)"
@update:to="emit('update:dateTo', $event)"
/>
</div>
<div class="filters__bottom-mid">
<button
type="button"
class="chip-btn"
:class="{ 'chip-btn--on': unseenOnly }"
title="Zeile antippen zum Umschalten"
@click="emit('update:unseenOnly', !unseenOnly)"
>
Nur ungesehen
</button>
<button
type="button"
class="chip-btn"
:disabled="loading || unseenTotal === 0"
@click="emit('mark-all-seen')"
>
Alle gesehen
</button>
<span v-if="unseenTotal > 0" class="badge badge-new">{{ unseenTotal }} neu</span>
</div>
<div class="filters__bottom-right">
<button type="button" class="chip-btn chip-btn--action" :disabled="loading" @click="emit('refresh')">
</button>
<button type="button" class="chip-btn chip-btn--action" title="Filter zurücksetzen" @click="emit('reset')">
Zurücksetzen
</button>
<span class="filters__stats">
<strong>{{ total }}</strong> Einträge
</span>
</div>
</div>
</section>
</template>
<style scoped>
.filters {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.filters__search {
position: relative;
}
.filters__search-input {
width: 100%;
padding: 0.4rem 0.65rem 0.4rem 2.15rem;
font-size: 0.82rem;
}
.filters__icon {
position: absolute;
left: 0.65rem;
top: 50%;
transform: translateY(-50%);
width: 0.95rem;
height: 0.95rem;
color: var(--text-muted);
pointer-events: none;
}
/* Typ, Format, Codec, Container 2×2 Desktop, 1 Spalte Mobil */
.filters__chips {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.35rem 0.75rem;
}
@media (min-width: 1100px) {
.filters__chips {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.chip-group {
display: flex;
align-items: flex-start;
gap: 0.35rem;
min-width: 0;
}
.chip-group__label {
flex-shrink: 0;
width: 4.25rem;
padding-top: 0.22rem;
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
line-height: 1.2;
}
.chip-group__label--inline {
width: auto;
padding-top: 0.35rem;
margin-right: 0.15rem;
}
.chip-group__btns {
display: flex;
flex-wrap: wrap;
gap: 0.28rem;
min-width: 0;
}
.chip-btn {
padding: 0.22rem 0.55rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 600;
line-height: 1.25;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s;
}
.chip-btn:hover:not(:disabled) {
border-color: var(--border-strong);
color: var(--text);
background: var(--bg-hover);
}
.chip-btn--on {
background: rgba(99, 102, 241, 0.35);
border-color: var(--accent-hover);
color: #fff;
}
.chip-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.chip-btn--action {
font-size: 0.72rem;
padding: 0.22rem 0.5rem;
}
/* Zeitraum + Gesehen + Aktionen in einer Zeile */
.filters__bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.65rem;
padding-top: 0.35rem;
border-top: 1px solid var(--border);
}
.filters__bottom-left {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
flex: 1 1 280px;
min-width: 0;
}
.filters__bottom-mid {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.28rem;
flex: 0 1 auto;
}
.filters__bottom-right {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.28rem;
margin-left: auto;
flex: 0 1 auto;
}
.filters__stats {
font-size: 0.72rem;
color: var(--text-muted);
white-space: nowrap;
}
.filters__stats strong {
color: var(--text);
}
.filters__new {
color: var(--accent-hover);
}
@media (max-width: 700px) {
.filters__bottom-right {
margin-left: 0;
width: 100%;
justify-content: space-between;
}
}
</style>
+257
View File
@@ -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>
+59
View File
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { formatDisplay } from '../utils/names'
defineProps<{
original: string | null | undefined
renamed: string | null | undefined
compact?: boolean
}>()
</script>
<template>
<div class="name-pair" :class="{ 'name-pair--compact': compact }">
<div class="name-pair__row">
<span class="name-pair__label">{{ compact ? 'O' : 'Original' }}</span>
<span class="name-cell name-cell--original">{{ formatDisplay(original) }}</span>
</div>
<div class="name-pair__row">
<span class="name-pair__label">{{ compact ? 'N' : 'Neu' }}</span>
<span class="name-cell name-cell--new">{{ formatDisplay(renamed) }}</span>
</div>
</div>
</template>
<style scoped>
.name-pair {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.name-pair__row {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.name-pair__label {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
}
.name-pair--compact {
gap: 0.35rem;
}
.name-pair--compact .name-pair__label {
display: inline-block;
min-width: 1.1rem;
}
.name-pair--compact .name-cell {
font-size: 0.8rem;
line-height: 1.25;
}
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
import { normalizeObjectType, objectTypeLabel } from '../utils/objectType'
const props = defineProps<{
value: string | null | undefined
}>()
const kind = computed(() => normalizeObjectType(props.value))
const label = computed(() => objectTypeLabel(props.value))
</script>
<template>
<span
v-if="kind !== 'unknown'"
class="badge object-type-badge"
:class="`object-type-badge--${kind}`"
>
{{ label }}
</span>
<span v-else-if="value?.trim()" class="badge object-type-badge object-type-badge--unknown">
{{ value.trim() }}
</span>
<span v-else class="object-type-badge--empty"></span>
</template>
<style scoped>
.object-type-badge {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
white-space: nowrap;
}
.object-type-badge--movie {
background: rgba(99, 102, 241, 0.35);
color: #e0e7ff;
border: 1px solid rgba(129, 140, 248, 0.45);
}
.object-type-badge--episode {
background: rgba(52, 211, 153, 0.2);
color: #a7f3d0;
border: 1px solid rgba(52, 211, 153, 0.4);
}
.object-type-badge--unknown {
background: rgba(255, 255, 255, 0.08);
color: var(--text-muted);
border: 1px solid var(--border);
}
.object-type-badge--empty {
color: var(--text-muted);
font-size: 0.85rem;
}
</style>