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,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>
|
||||
Reference in New Issue
Block a user