Files
filebot-media-browser/frontend/src/components/MobileCards.vue
T
HousemannandClaude Sonnet 4.6 7a7a997f15 Add cover URL edit button and modal
Rows without a cover (cover_url NULL or N/A) show a + button.
Clicking it opens a modal to enter a URL which is saved via new
PATCH /api/records/{id}/cover_url endpoint. cover_image is cleared
so n8n picks it up on the next backfill run.

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

292 lines
6.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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]
'edit-cover': [record: MediaRecord]
}>()
function needsCover(record: MediaRecord): boolean {
return !record.cover_mime_type && (!record.cover_url || record.cover_url === 'N/A')
}
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">
<div class="cover-wrap">
<CoverImage :url="row.cover_url" :id="row.id" :has-cover-in-db="Boolean(row.cover_mime_type)" size="card" />
<button
v-if="needsCover(row)"
class="cover-edit-btn"
title="Cover-URL eingeben"
@click.stop="emit('edit-cover', row)"
></button>
</div>
<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;
}
.cover-wrap {
position: relative;
flex-shrink: 0;
}
.cover-edit-btn {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.45);
border: 2px dashed var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-size: 1.4rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.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>