pp-planer/resources/js/Components/SlideGrid.vue
Thorsten Bus d915f8cfc2 feat: Wave 2 - Service list, Song CRUD, Slide upload, Arrangements, Song matching, Translation
T8: Service List Page
- ServiceController with index, finalize, reopen actions
- Services/Index.vue with status indicators (songs mapped/arranged, slides uploaded)
- German UI with finalize/reopen toggle buttons
- Status aggregation via SQL subqueries for efficiency
- Tests: 3 passing (46 assertions)

T9: Song CRUD Backend
- SongController with full REST API (index, store, show, update, destroy)
- SongService for default groups/arrangements creation
- SongRequest validation (title required, ccli_id unique)
- Search by title and CCLI ID
- last_used_in_service accessor via service_songs join
- Tests: 20 passing (85 assertions)

T10: Slide Upload Component
- SlideController with store, destroy, updateExpireDate
- SlideUploader.vue with vue3-dropzone drag-and-drop
- SlideGrid.vue with thumbnail grid and inline expire date editing
- Multi-format support: images (sync), PPT (async job), ZIP (extract)
- Type validation: information (global), moderation/sermon (service-specific)
- Tests: 15 passing (37 assertions)

T11: Arrangement Configurator
- ArrangementController with store, clone, update, destroy
- ArrangementConfigurator.vue with vue-draggable-plus
- Drag-and-drop arrangement editor with colored group pills
- Clone from default or existing arrangement
- Color picker for group customization
- Prevent deletion of last arrangement
- Tests: 4 passing (17 assertions)

T12: Song Matching Service
- SongMatchingService with autoMatch, manualAssign, requestCreation, unassign
- ServiceSongController API endpoints for song assignment
- Auto-match by CCLI ID during CTS sync
- Manual assignment with searchable song select
- Email request for missing songs (MissingSongRequest mailable)
- Tests: 14 passing (33 assertions)

T13: Translation Service
- TranslationService with fetchFromUrl, importTranslation, removeTranslation
- TranslationController API endpoints
- URL scraping (best-effort HTTP fetch with strip_tags)
- Line-count distribution algorithm (match original slide line counts)
- Mark song as translated, remove translation
- Tests: 18 passing (18 assertions)

All tests passing: 103/103 (488 assertions)
Build: ✓ Vite production build successful
German UI: All user-facing text in German with 'Du' form
2026-03-01 19:55:37 +01:00

341 lines
15 KiB
Vue

<script setup>
import { ref, computed } from 'vue'
import { router } from '@inertiajs/vue3'
import ConfirmDialog from '@/Components/ConfirmDialog.vue'
import LoadingSpinner from '@/Components/LoadingSpinner.vue'
const props = defineProps({
slides: {
type: Array,
default: () => [],
},
type: {
type: String,
required: true,
validator: (v) => ['information', 'moderation', 'sermon'].includes(v),
},
showExpireDate: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['deleted', 'updated'])
// Delete confirmation state
const confirmingDelete = ref(false)
const slideToDelete = ref(null)
const deleting = ref(false)
// Inline expire date editing
const editingExpireId = ref(null)
const editingExpireValue = ref('')
// Sort slides: newest first
const sortedSlides = computed(() => {
return [...props.slides].sort(
(a, b) => new Date(b.uploaded_at) - new Date(a.uploaded_at),
)
})
function thumbnailUrl(slide) {
if (!slide.thumbnail_filename) return null
return `/storage/${slide.thumbnail_filename}`
}
function fullImageUrl(slide) {
if (!slide.stored_filename) return null
return `/storage/${slide.stored_filename}`
}
function formatDate(dateStr) {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
function formatDateTime(dateStr) {
if (!dateStr) return '—'
const d = new Date(dateStr)
return d.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
// Delete slide
function promptDelete(slide) {
slideToDelete.value = slide
confirmingDelete.value = true
}
function cancelDelete() {
confirmingDelete.value = false
slideToDelete.value = null
}
function confirmDelete() {
if (!slideToDelete.value) return
deleting.value = true
router.delete(route('slides.destroy', slideToDelete.value.id), {
preserveScroll: true,
preserveState: true,
onSuccess: () => {
confirmingDelete.value = false
slideToDelete.value = null
deleting.value = false
emit('deleted')
},
onError: () => {
deleting.value = false
},
})
}
// Expire date editing
function startEditExpire(slide) {
editingExpireId.value = slide.id
editingExpireValue.value = slide.expire_date
? new Date(slide.expire_date).toISOString().split('T')[0]
: ''
}
function saveExpireDate(slide) {
router.patch(
route('slides.update-expire-date', slide.id),
{ expire_date: editingExpireValue.value || null },
{
preserveScroll: true,
preserveState: true,
onSuccess: () => {
editingExpireId.value = null
emit('updated')
},
},
)
}
function cancelEditExpire() {
editingExpireId.value = null
editingExpireValue.value = ''
}
function isExpireSoon(expireDate) {
if (!expireDate) return false
const diff = new Date(expireDate) - new Date()
return diff > 0 && diff < 7 * 24 * 60 * 60 * 1000 // 7 days
}
function isExpired(expireDate) {
if (!expireDate) return false
return new Date(expireDate) < new Date()
}
</script>
<template>
<div class="slide-grid">
<!-- Empty state -->
<div
v-if="sortedSlides.length === 0"
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-gray-50/50 py-10"
>
<svg class="h-10 w-10 text-gray-300 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
<p class="text-sm text-gray-400">Noch keine Folien vorhanden</p>
</div>
<!-- Grid of thumbnails -->
<div
v-else
class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
>
<div
v-for="slide in sortedSlides"
:key="slide.id"
class="slide-card group relative overflow-hidden rounded-xl border border-gray-200/80 bg-white shadow-sm transition-all duration-300 hover:shadow-md hover:border-gray-300 hover:-translate-y-0.5"
>
<!-- PPT processing indicator -->
<div
v-if="slide._processing"
class="absolute inset-0 z-10 flex flex-col items-center justify-center bg-white/90 backdrop-blur-sm"
>
<LoadingSpinner size="md" />
<span class="mt-2 text-xs font-medium text-gray-500">Verarbeitung läuft...</span>
</div>
<!-- Thumbnail -->
<div class="relative aspect-video overflow-hidden bg-gray-900">
<img
v-if="thumbnailUrl(slide)"
:src="thumbnailUrl(slide)"
:alt="slide.original_filename"
class="h-full w-full object-contain transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
<div
v-else
class="flex h-full w-full items-center justify-center bg-gradient-to-br from-gray-800 to-gray-900"
>
<svg class="h-8 w-8 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5a2.25 2.25 0 002.25-2.25V5.25a2.25 2.25 0 00-2.25-2.25H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" />
</svg>
</div>
<!-- Delete button overlay -->
<button
@click.stop="promptDelete(slide)"
class="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-lg bg-black/50 text-white/80 opacity-0 backdrop-blur-sm transition-all duration-200 hover:bg-red-600 hover:text-white group-hover:opacity-100"
title="Löschen"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
<!-- Full image link overlay -->
<a
v-if="fullImageUrl(slide)"
:href="fullImageUrl(slide)"
target="_blank"
class="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-lg bg-black/50 text-white/80 opacity-0 backdrop-blur-sm transition-all duration-200 hover:bg-white/90 hover:text-gray-800 group-hover:opacity-100"
title="Vollbild öffnen"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
</div>
<!-- Metadata -->
<div class="px-3 py-2.5">
<!-- Upload info (muted) -->
<div class="flex items-center gap-1.5 text-[11px] text-gray-400">
<svg class="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="truncate">{{ formatDateTime(slide.uploaded_at) }}</span>
</div>
<div
v-if="slide.uploader_name"
class="mt-0.5 flex items-center gap-1.5 text-[11px] text-gray-400"
>
<svg class="h-3 w-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" />
</svg>
<span class="truncate">{{ slide.uploader_name }}</span>
</div>
<!-- Expire date (prominent for information slides) -->
<div
v-if="showExpireDate"
class="mt-2"
>
<!-- Display mode -->
<div
v-if="editingExpireId !== slide.id"
class="flex items-center gap-1.5 cursor-pointer rounded-lg px-2 py-1 -mx-1 transition-colors hover:bg-gray-50"
@click="startEditExpire(slide)"
:title="'Ablaufdatum ändern'"
>
<svg
class="h-3.5 w-3.5 shrink-0"
:class="{
'text-red-500': isExpired(slide.expire_date),
'text-amber-500': isExpireSoon(slide.expire_date),
'text-emerald-500': slide.expire_date && !isExpired(slide.expire_date) && !isExpireSoon(slide.expire_date),
'text-gray-300': !slide.expire_date,
}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
<span
class="text-xs font-semibold"
:class="{
'text-red-600': isExpired(slide.expire_date),
'text-amber-600': isExpireSoon(slide.expire_date),
'text-gray-700': slide.expire_date && !isExpired(slide.expire_date) && !isExpireSoon(slide.expire_date),
'text-gray-400 font-normal': !slide.expire_date,
}"
>
{{ slide.expire_date ? formatDate(slide.expire_date) : 'Kein Ablaufdatum' }}
</span>
<!-- Tiny edit icon -->
<svg class="h-3 w-3 text-gray-300 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
</svg>
</div>
<!-- Edit mode -->
<div
v-else
class="flex items-center gap-1.5"
>
<input
v-model="editingExpireValue"
type="date"
class="w-full rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 shadow-sm focus:border-amber-400 focus:outline-none focus:ring-1 focus:ring-amber-400/40"
@keydown.enter="saveExpireDate(slide)"
@keydown.escape="cancelEditExpire"
/>
<button
@click="saveExpireDate(slide)"
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-amber-500 text-white shadow-sm transition hover:bg-amber-600"
title="Speichern"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
<button
@click="cancelEditExpire"
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-gray-200 bg-white text-gray-500 shadow-sm transition hover:bg-gray-50"
title="Abbrechen"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Delete confirmation dialog -->
<ConfirmDialog
:show="confirmingDelete"
title="Folie löschen?"
:message="`Möchtest du die Folie '${slideToDelete?.original_filename || ''}' wirklich löschen?`"
confirm-label="Löschen"
cancel-label="Abbrechen"
variant="danger"
@confirm="confirmDelete"
@cancel="cancelDelete"
/>
</div>
</template>
<style scoped>
.slide-card {
contain: layout;
}
.slide-card img {
image-rendering: auto;
}
</style>