Files
filebot-media-browser/frontend/src/components/DataTable.vue
T
HousemannandClaude Sonnet 4.6 b5366783b1 Cover-Bilder aus DB laden mit Fallback auf URL-Proxy
- API: neuer Endpunkt GET /api/records/{id}/cover liefert BYTEA direkt
  aus der DB; cover_mime_type in COLUMNS aufgenommen
- Frontend: CoverImage lädt primär aus DB (/api/records/{id}/cover),
  fällt bei 404 automatisch auf den URL-Proxy zurück
- types.ts: cover_mime_type zum MediaRecord-Interface hinzugefügt
- scripts/deploy.sh: einmaliges SSH-Passwort für alle scp-Transfers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-17 19:51:23 +02:00

418 lines
10 KiB
Vue

<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,
id: row.original.id,
hasCoverInDb: Boolean(row.original.cover_mime_type),
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>