- 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>
|
||||
@@ -32,7 +32,7 @@
|
||||
<span>{{ currentTheme === 'light' ? __('api.dark_mode') : __('api.light_mode') }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="!aiAvailable"
|
||||
v-if="!effectiveAiAvailable"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-rose-700 shadow-sm dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full bg-rose-500"></span>
|
||||
@@ -71,6 +71,10 @@
|
||||
<ImageContextMenu
|
||||
v-if="currentOverlayComponent === 'contextMenu' && selectedImage"
|
||||
:image="selectedImage"
|
||||
:allow-ai-styles="allowAiStyles"
|
||||
:show-print-button="showPrintButton"
|
||||
:gallery-slug="gallerySlug"
|
||||
:ai-available="effectiveAiAvailable"
|
||||
@close="closeOverlays"
|
||||
@print="printImage"
|
||||
@download="downloadImage"
|
||||
@@ -103,7 +107,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { Head, usePage } from '@inertiajs/vue3';
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import GalleryGrid from '../Components/GalleryGrid.vue';
|
||||
@@ -118,12 +122,22 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: 'Gallery',
|
||||
},
|
||||
gallery: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
images: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const page = usePage();
|
||||
const settings = computed(() => page.props.settings || {});
|
||||
const gallerySlug = computed(() => props.gallery?.slug || 'default');
|
||||
const allowAiStyles = computed(() => props.gallery?.allow_ai_styles !== false);
|
||||
const allowPrint = computed(() => props.gallery?.allow_print !== false);
|
||||
|
||||
const images = ref([]);
|
||||
const imagesPerPage = 12;
|
||||
const currentPage = ref(1);
|
||||
@@ -194,6 +208,10 @@ const paginatedImages = computed(() => {
|
||||
});
|
||||
|
||||
const refreshIntervalSeconds = computed(() => Math.max(1, Math.round(refreshIntervalMs.value / 1000)));
|
||||
const showPrintButton = computed(() => {
|
||||
return (settings.value.show_print_button ?? true) && allowPrint.value;
|
||||
});
|
||||
const effectiveAiAvailable = computed(() => allowAiStyles.value && aiAvailable.value);
|
||||
const formattedLastRefresh = computed(() =>
|
||||
new Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
@@ -236,8 +254,15 @@ const toggleTheme = () => {
|
||||
};
|
||||
|
||||
const checkAiStatus = () => {
|
||||
if (!allowAiStyles.value) {
|
||||
aiAvailable.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
axios
|
||||
.get('/api/ai-status')
|
||||
.get('/api/ai-status', {
|
||||
params: { gallery: gallerySlug.value },
|
||||
})
|
||||
.then((response) => {
|
||||
aiAvailable.value = response.data.some((provider) => provider.available);
|
||||
})
|
||||
@@ -258,7 +283,9 @@ const fetchImages = (options = { silent: false }) => {
|
||||
}
|
||||
|
||||
axios
|
||||
.get('/api/images')
|
||||
.get('/api/images', {
|
||||
params: { gallery: gallerySlug.value },
|
||||
})
|
||||
.then((response) => {
|
||||
if (Array.isArray(response.data)) {
|
||||
images.value = response.data;
|
||||
@@ -294,7 +321,7 @@ const showContextMenu = (image) => {
|
||||
};
|
||||
|
||||
const printImage = () => {
|
||||
if (!selectedImage.value) {
|
||||
if (!selectedImage.value || !showPrintButton.value) {
|
||||
return;
|
||||
}
|
||||
currentOverlayComponent.value = 'printQuantityModal';
|
||||
@@ -311,7 +338,7 @@ const downloadImage = (imageParam = null) => {
|
||||
axios
|
||||
.post(
|
||||
'/api/download-image',
|
||||
{ image_path: image.path },
|
||||
{ image_path: image.path, gallery: gallerySlug.value },
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
.then((response) => {
|
||||
@@ -348,6 +375,7 @@ const handlePrintConfirmed = (quantity) => {
|
||||
image_id: identifier,
|
||||
image_path: selectedImage.value.path,
|
||||
quantity,
|
||||
gallery: gallerySlug.value,
|
||||
})
|
||||
.then((response) => {
|
||||
console.log('Print request sent successfully:', response.data);
|
||||
@@ -364,6 +392,11 @@ const handlePrintConfirmed = (quantity) => {
|
||||
};
|
||||
|
||||
const applyStyle = (style, imageId) => {
|
||||
if (!allowAiStyles.value) {
|
||||
showToast('Stilwechsel ist für diese Galerie deaktiviert.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetImageId = imageId ?? getImageIdentifier(selectedImage.value);
|
||||
if (!targetImageId) {
|
||||
showToast('Kein Bild ausgewählt.', 'error');
|
||||
@@ -379,6 +412,7 @@ const applyStyle = (style, imageId) => {
|
||||
.post('/api/images/style-change', {
|
||||
image_id: targetImageId,
|
||||
style_id: style.id,
|
||||
gallery: gallerySlug.value,
|
||||
})
|
||||
.then((response) => {
|
||||
const promptId = response.data.prompt_id;
|
||||
@@ -386,7 +420,9 @@ const applyStyle = (style, imageId) => {
|
||||
|
||||
if (plugin === 'ComfyUi') {
|
||||
axios
|
||||
.get(`/api/comfyui-url?style_id=${style.id}`)
|
||||
.get('/api/comfyui-url', {
|
||||
params: { style_id: style.id, gallery: gallerySlug.value },
|
||||
})
|
||||
.then((comfyResponse) => {
|
||||
const comfyUiBaseUrl = comfyResponse.data.comfyui_url;
|
||||
const wsUrl = `ws://${new URL(comfyUiBaseUrl).host}/ws`;
|
||||
@@ -403,7 +439,9 @@ const applyStyle = (style, imageId) => {
|
||||
|
||||
if (processingProgress.value >= 100) {
|
||||
axios
|
||||
.get(`/api/images/fetch-styled/${promptId}`)
|
||||
.get(`/api/images/fetch-styled/${promptId}`, {
|
||||
params: { gallery: gallerySlug.value },
|
||||
})
|
||||
.then((imageResponse) => {
|
||||
styledImage.value = imageResponse.data.styled_image;
|
||||
currentOverlayComponent.value = 'styledImageDisplay';
|
||||
@@ -443,7 +481,9 @@ const applyStyle = (style, imageId) => {
|
||||
} else {
|
||||
const pollForStyledImage = () => {
|
||||
axios
|
||||
.get(`/api/images/fetch-styled/${promptId}`)
|
||||
.get(`/api/images/fetch-styled/${promptId}`, {
|
||||
params: { gallery: gallerySlug.value },
|
||||
})
|
||||
.then((imageResponse) => {
|
||||
styledImage.value = imageResponse.data.styled_image;
|
||||
currentOverlayComponent.value = 'styledImageDisplay';
|
||||
@@ -482,8 +522,28 @@ const keepStyledImage = () => {
|
||||
};
|
||||
|
||||
const deleteStyledImage = () => {
|
||||
currentOverlayComponent.value = null;
|
||||
styledImage.value = null;
|
||||
if (!styledImage.value?.id) {
|
||||
currentOverlayComponent.value = null;
|
||||
styledImage.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
axios
|
||||
.delete(`/api/images/${styledImage.value.id}/styled`, {
|
||||
params: { gallery: gallerySlug.value },
|
||||
})
|
||||
.then(() => {
|
||||
showToast('Bild gelöscht.', 'success');
|
||||
fetchImages({ silent: true });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error deleting styled image:', error);
|
||||
showToast(error.response?.data?.error || 'Bild konnte nicht gelöscht werden.', 'error');
|
||||
})
|
||||
.finally(() => {
|
||||
currentOverlayComponent.value = null;
|
||||
styledImage.value = null;
|
||||
});
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
@@ -541,7 +601,9 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
axios
|
||||
.get('/api/image-refresh-interval')
|
||||
.get('/api/image-refresh-interval', {
|
||||
params: { gallery: gallerySlug.value },
|
||||
})
|
||||
.then((response) => {
|
||||
startAutoRefresh(response.data.interval * 1000);
|
||||
})
|
||||
@@ -551,7 +613,9 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
axios
|
||||
.get('/api/max-copies-setting')
|
||||
.get('/api/max-copies-setting', {
|
||||
params: { gallery: gallerySlug.value },
|
||||
})
|
||||
.then((response) => {
|
||||
maxCopiesSetting.value = response.data.max_copies;
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user