- Galerien sind nun eine Entität - es kann mehrere geben
- Neues Sparkbooth-Upload-Feature: Endpoint /api/sparkbooth/upload (Token-basiert pro Galerie), Controller Api/SparkboothUploadController, Migration 2026_01_21_000001_add_upload_fields_to_galleries_table.php mit Upload-Flags/Token/Expiry;
Galerie-Modell und Factory/Seeder entsprechend erweitert.
- Filament: Neue Setup-Seite SparkboothSetup (mit View) zur schnellen Galerie- und Token-Erstellung inkl. QR/Endpoint/Snippet;
Galerie-Link-Views nutzen jetzt simple-qrcode (Composer-Dependency hinzugefügt) und bieten PNG-Download.
- Galerie-Tabelle: Slug/Pfad-Spalten entfernt, Action „Link-Details“ mit Modal; Created-at-Spalte hinzugefügt.
- Zugriffshärtung: Galerie-IDs in API (ImageController, Download/Print) geprüft; GalleryAccess/Middleware + Gallery-Modell/Slug-UUID
eingeführt; GalleryAccess-Inertia-Seite.
- UI/UX: LoadingSpinner/StyledImageDisplay verbessert, Delete-Confirm, Übersetzungen ergänzt.
This commit is contained in:
137
resources/js/Pages/GalleryAccess.vue
Normal file
137
resources/js/Pages/GalleryAccess.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<Head :title="__('api.gallery.access_title')" />
|
||||
<div class="min-h-screen bg-gradient-to-br from-slate-100 via-white to-slate-200 text-slate-900 transition-colors duration-500 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 dark:text-slate-100">
|
||||
<div class="flex min-h-screen items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-xl space-y-8 rounded-3xl border border-slate-200/70 bg-white/80 p-8 shadow-2xl backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||||
<div class="space-y-3">
|
||||
<p class="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
|
||||
Style Gallery
|
||||
</p>
|
||||
<h1 class="text-3xl font-semibold text-slate-900 dark:text-white">
|
||||
{{ __('api.gallery.access_title') }}
|
||||
</h1>
|
||||
<p v-if="expired" class="text-sm text-rose-600 dark:text-rose-300">
|
||||
{{ __('api.gallery.expired') }}
|
||||
</p>
|
||||
<p v-else class="text-sm text-slate-600 dark:text-slate-300">
|
||||
{{ props.flashMessage || __('api.gallery.password_required') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs text-slate-600 dark:text-slate-300">
|
||||
<span v-if="formattedExpiresAt" class="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 dark:bg-white/10">
|
||||
<span class="h-2 w-2 rounded-full bg-amber-400"></span>
|
||||
{{ __('api.gallery.expires_at_hint', { datetime: formattedExpiresAt }) }}
|
||||
</span>
|
||||
<span v-if="props.accessDurationMinutes" class="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 dark:bg-white/10">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||
{{ __('api.gallery.duration_hint', { minutes: props.accessDurationMinutes }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form v-if="requiresPassword && !expired" @submit.prevent="submit" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label for="password" class="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||
{{ __('api.gallery.password_label') }}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
class="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-base text-slate-900 shadow-sm transition focus:border-emerald-400 focus:outline-none focus:ring-2 focus:ring-emerald-400/40 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||
:aria-invalid="Boolean(form.errors.password)"
|
||||
/>
|
||||
<p v-if="form.errors.password" class="text-sm text-rose-500">
|
||||
{{ form.errors.password }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-full items-center justify-center rounded-2xl bg-emerald-500 px-4 py-3 text-base font-semibold text-white shadow-lg shadow-emerald-500/30 transition hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2 focus:ring-offset-white disabled:opacity-60 dark:focus:ring-offset-slate-900"
|
||||
:disabled="form.processing"
|
||||
>
|
||||
{{ form.processing ? '...' : __('api.gallery.submit_password') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-else-if="!expired" class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100">
|
||||
{{ __('api.gallery.password_required') }}
|
||||
</div>
|
||||
|
||||
<div v-if="expired" class="space-y-3 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
|
||||
<p>{{ __('api.gallery.expired') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Head, useForm } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
requiresPassword: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
gallery: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
expiresAt: {
|
||||
type: [String, Date, null],
|
||||
default: null,
|
||||
},
|
||||
accessDurationMinutes: {
|
||||
type: [Number, null],
|
||||
default: null,
|
||||
},
|
||||
expired: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
flashMessage: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const requiresPassword = computed(() => Boolean(props.requiresPassword));
|
||||
|
||||
const form = useForm({
|
||||
password: '',
|
||||
});
|
||||
|
||||
const formattedExpiresAt = computed(() => {
|
||||
if (!props.expiresAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(props.expiresAt);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
const hasGallery = Boolean(props.gallery?.slug);
|
||||
const routeName = hasGallery ? 'gallery.access.store' : 'gallery.access.default.store';
|
||||
const params = hasGallery ? { gallery: props.gallery.slug } : {};
|
||||
|
||||
form.post(route(routeName, params), {
|
||||
preserveScroll: true,
|
||||
onError: () => {
|
||||
form.password = '';
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user