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,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Filebot Media Browser</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "filebot-browser",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/vue-table": "^8.20.5",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
checkHealth,
|
||||
deleteRecord,
|
||||
fetchFilterOptions,
|
||||
fetchRecords,
|
||||
markAllSeen,
|
||||
setRecordSeen,
|
||||
} from './api'
|
||||
import type { FilterOptions, MediaRecord } from './types'
|
||||
import { isUnseen } from './utils/seen'
|
||||
import { useAutoRefresh } from './composables/useAutoRefresh'
|
||||
import { captureScroll, restoreScroll } from './utils/preserveScroll'
|
||||
import FilterBar from './components/FilterBar.vue'
|
||||
import { boundToIso, emptyDateTimeBound, type DateTimeBound } from './utils/datetime'
|
||||
import DataTable from './components/DataTable.vue'
|
||||
import MobileCards from './components/MobileCards.vue'
|
||||
|
||||
const records = ref<MediaRecord[]>([])
|
||||
const filterOptions = ref<FilterOptions | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const dbOk = ref<boolean | null>(null)
|
||||
const dbStatusDetail = ref<string | null>(null)
|
||||
|
||||
const search = ref('')
|
||||
const format = ref('')
|
||||
const codec = ref('')
|
||||
const container = ref('')
|
||||
const objectType = ref('')
|
||||
const dateFrom = ref<DateTimeBound>(emptyDateTimeBound())
|
||||
const dateTo = ref<DateTimeBound>(emptyDateTimeBound())
|
||||
const unseenOnly = ref(false)
|
||||
const unseenTotal = ref(0)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const isMobile = ref(false)
|
||||
const deletingId = ref<number | null>(null)
|
||||
const togglingSeenId = ref<number | null>(null)
|
||||
|
||||
const autoRefreshEnabled = ref(true)
|
||||
const autoRefreshPaused = computed(
|
||||
() => deletingId.value !== null || togglingSeenId.value !== null
|
||||
)
|
||||
|
||||
function updateViewport() {
|
||||
isMobile.value = window.matchMedia('(max-width: 900px)').matches
|
||||
}
|
||||
|
||||
function fetchParams() {
|
||||
return {
|
||||
search: search.value || undefined,
|
||||
format: format.value || undefined,
|
||||
codec: codec.value || undefined,
|
||||
container: container.value || undefined,
|
||||
objectType: objectType.value || undefined,
|
||||
createdFrom: boundToIso(dateFrom.value),
|
||||
createdTo: boundToIso(dateTo.value),
|
||||
unseenOnly: unseenOnly.value || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function applyRecordsFromApi(options?: { preserveScroll?: boolean }) {
|
||||
const snap = options?.preserveScroll ? captureScroll() : null
|
||||
const dataRes = await fetchRecords(fetchParams())
|
||||
records.value = dataRes.records
|
||||
unseenTotal.value = dataRes.unseen_total ?? 0
|
||||
if (snap) await restoreScroll(snap)
|
||||
}
|
||||
|
||||
async function silentRefresh() {
|
||||
try {
|
||||
await applyRecordsFromApi({ preserveScroll: true })
|
||||
} catch {
|
||||
/* Hintergrund-Refresh – Fehler nicht anzeigen */
|
||||
}
|
||||
}
|
||||
|
||||
const autoRefresh = useAutoRefresh(silentRefresh, {
|
||||
enabled: autoRefreshEnabled,
|
||||
paused: autoRefreshPaused,
|
||||
})
|
||||
|
||||
async function loadData(options?: { silent?: boolean; preserveScroll?: boolean }) {
|
||||
const silent = options?.silent ?? false
|
||||
const preserveScroll = options?.preserveScroll ?? silent
|
||||
if (!silent) loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [_, filtersRes] = await Promise.all([
|
||||
applyRecordsFromApi({ preserveScroll }),
|
||||
fetchFilterOptions(),
|
||||
])
|
||||
filterOptions.value = filtersRes
|
||||
} catch (e) {
|
||||
if (!silent) {
|
||||
error.value = e instanceof Error ? e.message : 'Unbekannter Fehler'
|
||||
}
|
||||
} finally {
|
||||
if (!silent) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const lastSyncedLabel = computed(() => {
|
||||
const t = autoRefresh.lastSyncedAt.value
|
||||
if (!t) return 'Live'
|
||||
return `Live · ${t.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}`
|
||||
})
|
||||
|
||||
function scheduleLoad() {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(loadData, 350)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
search.value = ''
|
||||
format.value = ''
|
||||
codec.value = ''
|
||||
container.value = ''
|
||||
objectType.value = ''
|
||||
dateFrom.value = emptyDateTimeBound()
|
||||
dateTo.value = emptyDateTimeBound()
|
||||
unseenOnly.value = false
|
||||
loadData()
|
||||
}
|
||||
|
||||
function applySeenUpdate(id: number, seenAt: string | null, wasUnseen: boolean) {
|
||||
const row = records.value.find((r) => r.id === id)
|
||||
if (!row) return
|
||||
row.seen_at = seenAt
|
||||
|
||||
if (wasUnseen && seenAt) {
|
||||
unseenTotal.value = Math.max(0, unseenTotal.value - 1)
|
||||
} else if (!wasUnseen && !seenAt) {
|
||||
unseenTotal.value += 1
|
||||
}
|
||||
|
||||
if (unseenOnly.value && seenAt) {
|
||||
records.value = records.value.filter((r) => r.id !== id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleSeen(record: MediaRecord) {
|
||||
const wasUnseen = isUnseen(record)
|
||||
togglingSeenId.value = record.id
|
||||
error.value = null
|
||||
try {
|
||||
const result = await setRecordSeen(record.id, wasUnseen)
|
||||
applySeenUpdate(record.id, result.seen_at, wasUnseen)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Status konnte nicht geändert werden'
|
||||
} finally {
|
||||
togglingSeenId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllSeen() {
|
||||
if (!window.confirm('Alle angezeigten (gefilterten) Einträge als gesehen markieren?')) return
|
||||
error.value = null
|
||||
try {
|
||||
const result = await markAllSeen(fetchParams())
|
||||
await loadData()
|
||||
if (result.marked === 0) {
|
||||
error.value = null
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Markieren fehlgeschlagen'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
const row = records.value.find((r) => r.id === id)
|
||||
const label = row?.file_name_new || row?.file_name_original || `ID ${id}`
|
||||
if (!window.confirm(`Eintrag wirklich löschen?\n\n${label}`)) return
|
||||
|
||||
deletingId.value = id
|
||||
error.value = null
|
||||
try {
|
||||
await deleteRecord(id)
|
||||
if (row && isUnseen(row)) {
|
||||
unseenTotal.value = Math.max(0, unseenTotal.value - 1)
|
||||
}
|
||||
records.value = records.value.filter((r) => r.id !== id)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Löschen fehlgeschlagen'
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
watch([format, codec, container, objectType, dateFrom, dateTo, unseenOnly], () => loadData(), {
|
||||
deep: true,
|
||||
})
|
||||
watch(search, () => scheduleLoad())
|
||||
|
||||
onMounted(async () => {
|
||||
updateViewport()
|
||||
window.addEventListener('resize', updateViewport)
|
||||
const health = await checkHealth()
|
||||
dbOk.value = health.ok
|
||||
dbStatusDetail.value = health.detail ?? null
|
||||
await loadData()
|
||||
autoRefresh.mount()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
autoRefresh.unmount()
|
||||
window.removeEventListener('resize', updateViewport)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="header">
|
||||
<div class="header__brand">
|
||||
<div class="header__logo">FB</div>
|
||||
<div>
|
||||
<h1>Filebot Media Browser</h1>
|
||||
<p class="header__sub">Rename-Kontrolle & Medien-Metadaten</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header__right">
|
||||
<span v-if="unseenTotal > 0" class="header__badge badge badge-new">
|
||||
{{ unseenTotal }} neu
|
||||
</span>
|
||||
<div
|
||||
class="header__sync"
|
||||
title="Tabelle wird alle 5 Sekunden automatisch aktualisiert"
|
||||
>
|
||||
<span
|
||||
class="header__sync-dot"
|
||||
:class="{ 'header__sync-dot--active': autoRefresh.isRefreshing.value }"
|
||||
/>
|
||||
{{ lastSyncedLabel }}
|
||||
</div>
|
||||
<div
|
||||
class="header__status"
|
||||
:class="dbOk === false ? 'header__status--err' : ''"
|
||||
:title="dbStatusDetail ?? undefined"
|
||||
>
|
||||
<span class="header__dot" />
|
||||
{{
|
||||
dbOk === null
|
||||
? '…'
|
||||
: dbOk
|
||||
? 'DB verbunden'
|
||||
: dbStatusDetail || 'DB nicht erreichbar'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<FilterBar
|
||||
v-model:search="search"
|
||||
v-model:format="format"
|
||||
v-model:codec="codec"
|
||||
v-model:container="container"
|
||||
v-model:object-type="objectType"
|
||||
v-model:date-from="dateFrom"
|
||||
v-model:date-to="dateTo"
|
||||
v-model:unseen-only="unseenOnly"
|
||||
:options="filterOptions"
|
||||
:loading="loading"
|
||||
:total="records.length"
|
||||
:unseen-total="unseenTotal"
|
||||
@refresh="() => loadData({ preserveScroll: true })"
|
||||
@reset="resetFilters"
|
||||
@mark-all-seen="handleMarkAllSeen"
|
||||
/>
|
||||
|
||||
<div v-if="error" class="alert alert--error">{{ error }}</div>
|
||||
<div v-else-if="loading && records.length === 0" class="loading">
|
||||
<div class="loading__spinner" />
|
||||
Daten werden geladen…
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-show="!isMobile" class="view-desktop">
|
||||
<DataTable
|
||||
:data="records"
|
||||
:deleting-id="deletingId"
|
||||
:toggling-seen-id="togglingSeenId"
|
||||
@delete="handleDelete"
|
||||
@toggle-seen="handleToggleSeen"
|
||||
/>
|
||||
</div>
|
||||
<div v-show="isMobile" class="view-mobile">
|
||||
<MobileCards
|
||||
:data="records"
|
||||
:deleting-id="deletingId"
|
||||
:toggling-seen-id="togglingSeenId"
|
||||
@delete="handleDelete"
|
||||
@toggle-seen="handleToggleSeen"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 1.25rem 1rem 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header__logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.header__sub {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header__badge {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.header__sync {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header__sync-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.header__sync-dot--active {
|
||||
animation: pulse 0.8s ease infinite;
|
||||
}
|
||||
|
||||
.header__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--success);
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.header__status--err {
|
||||
color: var(--danger);
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.header__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 2s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.alert--error {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.loading__spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { FilterOptions, RecordsResponse } from './types'
|
||||
|
||||
export interface FetchParams {
|
||||
search?: string
|
||||
format?: string
|
||||
codec?: string
|
||||
container?: string
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
unseenOnly?: boolean
|
||||
objectType?: string
|
||||
}
|
||||
|
||||
const fetchOpts: RequestInit = { cache: 'no-store' }
|
||||
|
||||
async function parseApiError(res: Response): Promise<string> {
|
||||
try {
|
||||
const data = (await res.json()) as { detail?: unknown }
|
||||
const d = data.detail
|
||||
if (typeof d === 'string') return d
|
||||
if (Array.isArray(d)) {
|
||||
return d
|
||||
.map((item) => (typeof item === 'object' && item && 'msg' in item ? String((item as { msg: string }).msg) : String(item)))
|
||||
.join('; ')
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return `API-Fehler: ${res.status}`
|
||||
}
|
||||
|
||||
function buildRecordsQuery(params: FetchParams = {}): URLSearchParams {
|
||||
const q = new URLSearchParams()
|
||||
if (params.search) q.set('search', params.search)
|
||||
if (params.format) q.set('format', params.format)
|
||||
if (params.codec) q.set('codec', params.codec)
|
||||
if (params.container) q.set('container', params.container)
|
||||
if (params.createdFrom) q.set('created_from', params.createdFrom)
|
||||
if (params.createdTo) q.set('created_to', params.createdTo)
|
||||
if (params.unseenOnly) q.set('unseen_only', 'true')
|
||||
if (params.objectType) q.set('object_type', params.objectType)
|
||||
return q
|
||||
}
|
||||
|
||||
export async function fetchRecords(params: FetchParams = {}): Promise<RecordsResponse> {
|
||||
const q = buildRecordsQuery(params)
|
||||
q.set('_', String(Date.now()))
|
||||
const res = await fetch(`/api/records?${q}`, fetchOpts)
|
||||
if (!res.ok) throw new Error(await parseApiError(res))
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function fetchFilterOptions(): Promise<FilterOptions> {
|
||||
const res = await fetch('/api/filters', fetchOpts)
|
||||
if (!res.ok) throw new Error(await parseApiError(res))
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function setRecordSeen(
|
||||
id: number,
|
||||
seen: boolean
|
||||
): Promise<{ id: number; seen_at: string | null }> {
|
||||
const res = await fetch(`/api/records/${id}/seen`, {
|
||||
...fetchOpts,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ seen }),
|
||||
})
|
||||
if (!res.ok) throw new Error(await parseApiError(res))
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function markAllSeen(params: FetchParams = {}): Promise<{ marked: number }> {
|
||||
const res = await fetch(`/api/records/mark-all-seen?${buildRecordsQuery(params)}`, {
|
||||
...fetchOpts,
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) throw new Error(await parseApiError(res))
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteRecord(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/records/${id}`, { ...fetchOpts, method: 'DELETE' })
|
||||
if (!res.ok) throw new Error(await parseApiError(res))
|
||||
}
|
||||
|
||||
export async function checkHealth(): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const res = await fetch('/api/health', fetchOpts)
|
||||
if (res.ok) return { ok: true }
|
||||
return { ok: false, detail: await parseApiError(res) }
|
||||
} catch (e) {
|
||||
return { ok: false, detail: e instanceof Error ? e.message : 'Netzwerkfehler' }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,72 @@
|
||||
import { onBeforeUnmount, ref, type Ref } from 'vue'
|
||||
|
||||
export const AUTO_REFRESH_INTERVAL_MS = 5000
|
||||
|
||||
export function useAutoRefresh(
|
||||
onRefresh: () => void | Promise<void>,
|
||||
options: {
|
||||
enabled: Ref<boolean>
|
||||
paused: Ref<boolean>
|
||||
}
|
||||
) {
|
||||
const lastSyncedAt = ref<Date | null>(null)
|
||||
const isRefreshing = ref(false)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let running = false
|
||||
|
||||
async function tick() {
|
||||
if (options.paused.value || !options.enabled.value || running) return
|
||||
if (document.visibilityState === 'hidden') return
|
||||
|
||||
running = true
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
await onRefresh()
|
||||
lastSyncedAt.value = new Date()
|
||||
} catch {
|
||||
/* stiller Fehler – nächster Versuch */
|
||||
} finally {
|
||||
running = false
|
||||
isRefreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
stop()
|
||||
timer = setInterval(() => {
|
||||
void tick()
|
||||
}, AUTO_REFRESH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibility() {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void tick()
|
||||
}
|
||||
}
|
||||
|
||||
function mount() {
|
||||
start()
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
|
||||
function unmount() {
|
||||
stop()
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
}
|
||||
|
||||
return {
|
||||
lastSyncedAt,
|
||||
isRefreshing,
|
||||
tick,
|
||||
mount,
|
||||
unmount,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './styles.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,175 @@
|
||||
:root {
|
||||
--bg: #2a3142;
|
||||
--bg-elevated: #343d52;
|
||||
--bg-card: #3f4a63;
|
||||
--bg-hover: #4a5672;
|
||||
--bg-input: #455068;
|
||||
--border: rgba(255, 255, 255, 0.14);
|
||||
--border-strong: rgba(255, 255, 255, 0.24);
|
||||
--text: #f8f9fc;
|
||||
--text-muted: #b4bdd1;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #a5b4fc;
|
||||
--accent-glow: rgba(99, 102, 241, 0.35);
|
||||
--success: #34d399;
|
||||
--warning: #fbbf24;
|
||||
--danger: #f87171;
|
||||
--danger-bg: rgba(248, 113, 113, 0.18);
|
||||
--radius: 12px;
|
||||
--radius-sm: 8px;
|
||||
--shadow: 0 10px 40px rgba(0, 0, 0, 0.18);
|
||||
--font: 'DM Sans', system-ui, sans-serif;
|
||||
--mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
min-height: 100vh;
|
||||
background-image:
|
||||
radial-gradient(ellipse 90% 55% at 50% -15%, rgba(99, 102, 241, 0.2), transparent),
|
||||
radial-gradient(ellipse 70% 45% at 100% 0%, rgba(52, 211, 153, 0.1), transparent),
|
||||
linear-gradient(180deg, #2f3648 0%, var(--bg) 45%);
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.55rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-format {
|
||||
background: rgba(99, 102, 241, 0.28);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.badge-warn {
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
background: rgba(52, 211, 153, 0.22);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.name-cell--original {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.name-cell--new {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.row-unseen td,
|
||||
.card-unseen {
|
||||
background: rgba(99, 102, 241, 0.14);
|
||||
}
|
||||
|
||||
.row-unseen td:first-child {
|
||||
box-shadow: inset 4px 0 0 var(--accent-hover);
|
||||
}
|
||||
|
||||
.card-unseen {
|
||||
border-color: rgba(129, 140, 248, 0.45);
|
||||
box-shadow: inset 4px 0 0 var(--accent-hover);
|
||||
}
|
||||
|
||||
.badge-new {
|
||||
background: rgba(99, 102, 241, 0.35);
|
||||
color: #e0e7ff;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export interface MediaRecord {
|
||||
id: number
|
||||
file_name_original: string | null
|
||||
file_name_new: string | null
|
||||
object_type: string | null
|
||||
cover_url: string | null
|
||||
movie_rating: string | number | null
|
||||
movie_votes: number | null
|
||||
video_compression_format: string | null
|
||||
video_codec_library: string | null
|
||||
audio_codec: string | null
|
||||
container_format: string | null
|
||||
standard_video_format: string | null
|
||||
exact_video_format: string | null
|
||||
fourk_resolution: string | null
|
||||
audio_codec_profile: string | null
|
||||
audio_channel_format: string | null
|
||||
audio_channel_count: string | null
|
||||
audio_channel_layout: string | null
|
||||
video_resolution: string | null
|
||||
video_width: number | null
|
||||
video_height: number | null
|
||||
video_bitdepth: number | null
|
||||
high_dynamic_range: string | null
|
||||
dolby_vision: string | null
|
||||
overall_bitrate: string | null
|
||||
video_bitrate: string | null
|
||||
audio_bitrate: string | null
|
||||
created_at: string | null
|
||||
seen_at: string | null
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
formats: string[]
|
||||
codecs: string[]
|
||||
containers: string[]
|
||||
audio_codecs: string[]
|
||||
object_types: string[]
|
||||
}
|
||||
|
||||
export interface RecordsResponse {
|
||||
records: MediaRecord[]
|
||||
total: number
|
||||
unseen_total: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Cover über API-Proxy laden (Hotlink/Referrer-Probleme). */
|
||||
export function coverImageSrc(url: string | null | undefined): string | null {
|
||||
if (!url) return null
|
||||
const cleaned = url.trim().replace(/^["']+|["']+$/g, '')
|
||||
if (!cleaned) return null
|
||||
if (cleaned.startsWith('http://') || cleaned.startsWith('https://')) {
|
||||
return `/api/cover?url=${encodeURIComponent(cleaned)}`
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
export interface DateTimeBound {
|
||||
date: string
|
||||
hour: string
|
||||
minute: string
|
||||
}
|
||||
|
||||
export const emptyDateTimeBound = (): DateTimeBound => ({
|
||||
date: '',
|
||||
hour: '00',
|
||||
minute: '00',
|
||||
})
|
||||
|
||||
export function boundToIso(bound: DateTimeBound): string | undefined {
|
||||
if (!bound.date) return undefined
|
||||
const local = new Date(
|
||||
`${bound.date}T${bound.hour.padStart(2, '0')}:${bound.minute.padStart(2, '0')}:00`
|
||||
)
|
||||
if (Number.isNaN(local.getTime())) return undefined
|
||||
return local.toISOString()
|
||||
}
|
||||
|
||||
export function formatBoundLabel(bound: DateTimeBound): string {
|
||||
if (!bound.date) return 'Datum wählen'
|
||||
return `${formatDateDe(bound.date)} ${bound.hour.padStart(2, '0')}:${bound.minute.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatDateDe(isoDate: string): string {
|
||||
const [y, m, d] = isoDate.split('-').map(Number)
|
||||
if (!y || !m || !d) return isoDate
|
||||
return new Date(y, m - 1, d).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0')
|
||||
}
|
||||
|
||||
export const HOURS = Array.from({ length: 24 }, (_, i) => pad2(i))
|
||||
export const MINUTES = Array.from({ length: 60 }, (_, i) => pad2(i))
|
||||
|
||||
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const MONTHS = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
]
|
||||
|
||||
export function getWeekdayLabels(): string[] {
|
||||
return WEEKDAYS
|
||||
}
|
||||
|
||||
export function getMonthLabel(monthIndex: number): string {
|
||||
return MONTHS[monthIndex] ?? ''
|
||||
}
|
||||
|
||||
/** Kalendertage inkl. Auffüllung für Mo-start Grid */
|
||||
export function getCalendarDays(year: number, month: number) {
|
||||
const first = new Date(year, month, 1)
|
||||
const last = new Date(year, month + 1, 0)
|
||||
let startPad = first.getDay() - 1
|
||||
if (startPad < 0) startPad = 6
|
||||
|
||||
const days: { date: string; day: number; currentMonth: boolean }[] = []
|
||||
|
||||
for (let i = startPad - 1; i >= 0; i--) {
|
||||
const d = new Date(year, month, -i)
|
||||
days.push({
|
||||
date: toIsoDate(d),
|
||||
day: d.getDate(),
|
||||
currentMonth: false,
|
||||
})
|
||||
}
|
||||
|
||||
for (let d = 1; d <= last.getDate(); d++) {
|
||||
days.push({
|
||||
date: toIsoDate(new Date(year, month, d)),
|
||||
day: d,
|
||||
currentMonth: true,
|
||||
})
|
||||
}
|
||||
|
||||
const totalCells = Math.ceil((startPad + last.getDate()) / 7) * 7
|
||||
let nextDay = 1
|
||||
while (days.length < totalCells) {
|
||||
const d = new Date(year, month + 1, nextDay++)
|
||||
days.push({
|
||||
date: toIsoDate(d),
|
||||
day: d.getDate(),
|
||||
currentMonth: false,
|
||||
})
|
||||
}
|
||||
|
||||
return days
|
||||
}
|
||||
|
||||
export function toIsoDate(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||||
}
|
||||
|
||||
export function isBoundActive(bound: DateTimeBound): boolean {
|
||||
return Boolean(bound.date)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Normalisiert für groben Vergleich (Punkte, Klammern, Leerzeichen). */
|
||||
export function normalizeFileName(name: string | null | undefined): string {
|
||||
if (!name) return ''
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[._\-()[\]]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function namesLikelyMatch(
|
||||
original: string | null | undefined,
|
||||
renamed: string | null | undefined
|
||||
): boolean {
|
||||
const a = normalizeFileName(original)
|
||||
const b = normalizeFileName(renamed)
|
||||
if (!a || !b) return a === b
|
||||
if (a === b) return true
|
||||
return a.includes(b) || b.includes(a)
|
||||
}
|
||||
|
||||
export function formatDisplay(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') return '—'
|
||||
return String(value)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type ObjectTypeKind = 'movie' | 'episode' | 'unknown'
|
||||
|
||||
export function normalizeObjectType(value: string | null | undefined): ObjectTypeKind {
|
||||
const s = (value ?? '').toLowerCase().trim()
|
||||
if (!s) return 'unknown'
|
||||
if (s === 'movie' || s === 'film' || s === 'movies') return 'movie'
|
||||
if (s === 'episode' || s === 'episodes' || s.includes('episode')) return 'episode'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function objectTypeLabel(value: string | null | undefined): string {
|
||||
const kind = normalizeObjectType(value)
|
||||
if (kind === 'movie') return 'Film'
|
||||
if (kind === 'episode') return 'Episode'
|
||||
const raw = value?.trim()
|
||||
return raw || '—'
|
||||
}
|
||||
|
||||
/** Anzeige auf Filter-Buttons (DB-Wert → lesbar). */
|
||||
export function objectTypeFilterLabel(dbValue: string): string {
|
||||
return objectTypeLabel(dbValue)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export interface ScrollSnapshot {
|
||||
windowY: number
|
||||
tableTop: number
|
||||
}
|
||||
|
||||
export function captureScroll(): ScrollSnapshot {
|
||||
const table = document.querySelector('.table-scroll')
|
||||
return {
|
||||
windowY: window.scrollY,
|
||||
tableTop: table instanceof HTMLElement ? table.scrollTop : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export async function restoreScroll(snapshot: ScrollSnapshot): Promise<void> {
|
||||
await nextTick()
|
||||
requestAnimationFrame(() => {
|
||||
const table = document.querySelector('.table-scroll')
|
||||
if (table instanceof HTMLElement) {
|
||||
table.scrollTop = snapshot.tableTop
|
||||
}
|
||||
window.scrollTo(0, snapshot.windowY)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MediaRecord } from '../types'
|
||||
|
||||
export function isUnseen(record: MediaRecord): boolean {
|
||||
return record.seen_at == null || record.seen_at === ''
|
||||
}
|
||||
|
||||
export function countUnseen(records: MediaRecord[]): number {
|
||||
return records.filter(isUnseen).length
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"jsx": "preserve",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user