- 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:
@@ -47,17 +47,17 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-4 py-3 text-left font-semibold text-slate-900 transition focus:outline-none focus-visible:ring-2 dark:text-white"
|
||||
:class="[aiAvailable ? 'border-white/20 bg-white/40 hover:border-emerald-400 hover:bg-white/70 dark:border-white/10 dark:bg-white/5' : 'border-rose-200 bg-rose-50 cursor-not-allowed opacity-70']"
|
||||
:disabled="!aiAvailable"
|
||||
:class="[effectiveAiAvailable ? 'border-white/20 bg-white/40 hover:border-emerald-400 hover:bg-white/70 dark:border-white/10 dark:bg-white/5' : 'border-rose-200 bg-rose-50 cursor-not-allowed opacity-70']"
|
||||
:disabled="!effectiveAiAvailable"
|
||||
@click="showStyleSelectorView = true"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="h-2 w-2 rounded-full"
|
||||
:class="aiAvailable ? 'bg-emerald-500' : 'bg-rose-500'"></span>
|
||||
:class="effectiveAiAvailable ? 'bg-emerald-500' : 'bg-rose-500'"></span>
|
||||
<div>
|
||||
<p class="text-base">Stile anzeigen</p>
|
||||
<p class="text-sm font-normal text-slate-500 dark:text-slate-400">
|
||||
{{ aiAvailable ? 'Lass die KI dein Motiv verzaubern' : 'AI-Dienste derzeit nicht verfügbar' }}
|
||||
{{ effectiveAiAvailable ? 'Lass die KI dein Motiv verzaubern' : 'AI-Dienste derzeit nicht verfügbar' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,6 +126,9 @@
|
||||
<StyleSelector
|
||||
class="max-h-[520px] flex-1"
|
||||
:image_id="image?.id ?? image?.image_id"
|
||||
:allow-ai-styles="props.allowAiStyles"
|
||||
:gallery-slug="props.gallerySlug"
|
||||
:ai-available-override="props.aiAvailable"
|
||||
@styleSelected="handleStyleSelected"
|
||||
@close="$emit('close')"
|
||||
/>
|
||||
@@ -149,12 +152,31 @@ const props = defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
allowAiStyles: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showPrintButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
gallerySlug: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
aiAvailable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['close', 'print', 'styleSelected', 'download']);
|
||||
|
||||
const settings = computed(() => page.props.settings || {});
|
||||
const showPrintButton = computed(() => settings.value.show_print_button ?? true);
|
||||
const showPrintButton = computed(() => {
|
||||
const fromSettings = settings.value.show_print_button ?? true;
|
||||
return fromSettings && props.showPrintButton;
|
||||
});
|
||||
|
||||
const shouldShowDownload = computed(() => {
|
||||
const hostname = window.location.hostname;
|
||||
@@ -166,23 +188,9 @@ const shouldShowDownload = computed(() => {
|
||||
});
|
||||
|
||||
const showStyleSelectorView = ref(false);
|
||||
const aiAvailable = ref(false);
|
||||
const effectiveAiAvailable = computed(() => props.allowAiStyles && props.aiAvailable);
|
||||
|
||||
const checkAiStatus = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/ai-status');
|
||||
aiAvailable.value = response.data.some(provider => provider.available);
|
||||
} catch (error) {
|
||||
console.error('Error checking AI status:', error);
|
||||
aiAvailable.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkAiStatus();
|
||||
// Check every 5 minutes
|
||||
setInterval(checkAiStatus, 300000);
|
||||
});
|
||||
onMounted(() => {});
|
||||
|
||||
watch(
|
||||
() => props.image,
|
||||
|
||||
@@ -1,38 +1,25 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white p-6 rounded-lg shadow-lg text-center flex flex-col items-center">
|
||||
<div class="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12 mb-4"></div>
|
||||
<p class="text-gray-700 text-lg">{{ __('api.loading_spinner.processing_image') }}</p>
|
||||
<p v-if="progress > 0" class="text-gray-700 text-sm mt-2">{{ progress }}%</p>
|
||||
<div class="fixed inset-0 z-[200] flex items-center justify-center bg-slate-900/80 backdrop-blur">
|
||||
<div class="relative flex w-full max-w-md flex-col items-center gap-4 rounded-3xl border border-white/10 bg-white/95 p-8 text-center shadow-2xl dark:bg-slate-900/95">
|
||||
<div class="relative">
|
||||
<div class="h-16 w-16 rounded-full border-4 border-white/40 border-t-emerald-400 animate-spin"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center text-sm font-semibold text-emerald-600 dark:text-emerald-300">
|
||||
{{ progress ? `${progress}%` : '...' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-lg font-semibold text-slate-900 dark:text-white">{{ __('api.loading_spinner.processing_image') }}</p>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-300">{{ __('api.loading_spinner.processing_wait') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
progress: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loader {
|
||||
border-top-color: #3498db; /* Blue color for the spinner */
|
||||
-webkit-animation: spinner 1.5s linear infinite;
|
||||
animation: spinner 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@-webkit-keyframes spinner {
|
||||
0% { -webkit-transform: rotate(0deg); }
|
||||
100% { -webkit-transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes spinner {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -66,6 +66,18 @@ const props = defineProps({
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
allowAiStyles: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
gallerySlug: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
},
|
||||
aiAvailableOverride: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['styleSelected', 'close']);
|
||||
@@ -75,22 +87,39 @@ const fetchStyles = async () => {
|
||||
loadError.value = null;
|
||||
|
||||
try {
|
||||
// Check AI availability first
|
||||
const aiStatusResponse = await axios.get('/api/ai-status');
|
||||
const aiStatus = aiStatusResponse.data;
|
||||
aiAvailable.value = aiStatus.some(provider => provider.available);
|
||||
|
||||
if (!aiAvailable.value) {
|
||||
loadError.value = 'AI-Dienste sind derzeit nicht verfügbar. Bitte versuchen Sie es später erneut.';
|
||||
if (!props.allowAiStyles) {
|
||||
aiAvailable.value = false;
|
||||
loadError.value = 'Stilwechsel ist für diese Galerie deaktiviert.';
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.aiAvailableOverride !== null) {
|
||||
aiAvailable.value = props.aiAvailableOverride;
|
||||
} else {
|
||||
aiAvailable.value = true;
|
||||
}
|
||||
|
||||
// Fetch styles only if AI is available
|
||||
const stylesResponse = await axios.get('/api/styles');
|
||||
styles.value = stylesResponse.data.filter(style => {
|
||||
// Only show styles from available providers
|
||||
return style.ai_model && style.ai_model.api_provider && style.ai_model.api_provider.enabled;
|
||||
const stylesResponse = await axios.get('/api/styles', {
|
||||
params: { gallery: props.gallerySlug },
|
||||
});
|
||||
|
||||
const payload = Array.isArray(stylesResponse.data) ? stylesResponse.data : [];
|
||||
|
||||
styles.value = payload.filter((style) => {
|
||||
if (! props.allowAiStyles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const aiModel = style.ai_model || style.aiModel || {};
|
||||
const provider = aiModel.primary_api_provider || aiModel.api_provider || {};
|
||||
|
||||
if (provider.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return aiModel && (provider || aiModel);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -105,7 +134,17 @@ const selectStyle = (style) => {
|
||||
emits('styleSelected', style, props.image_id);
|
||||
};
|
||||
|
||||
const buildPreviewPath = (preview) => `/storage/${preview}`;
|
||||
const buildPreviewPath = (preview) => {
|
||||
if (! preview) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preview.startsWith('http://') || preview.startsWith('https://') || preview.startsWith('/')) {
|
||||
return preview;
|
||||
}
|
||||
|
||||
return `/storage/${preview}`;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStyles();
|
||||
|
||||
@@ -1,41 +1,77 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
||||
<div class="bg-white p-4 rounded-lg shadow-lg max-w-3xl w-full text-center">
|
||||
<h2 class="text-xl font-bold mb-4">{{ __('api.styled_image_display.title') }}</h2>
|
||||
<img :src="image.path" alt="Styled Image" class="max-w-full h-auto mx-auto mb-4 rounded-md" />
|
||||
<div class="flex justify-center space-x-4">
|
||||
<button
|
||||
@click="$emit('keep', image)"
|
||||
class="px-6 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 focus:outline-hidden focus:ring-2 focus:ring-green-500 focus:ring-opacity-50"
|
||||
>
|
||||
{{ __('api.styled_image_display.keep_button') }}
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('delete', image)"
|
||||
class="px-6 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-hidden focus:ring-2 focus:ring-red-500 focus:ring-opacity-50"
|
||||
>
|
||||
{{ __('api.styled_image_display.delete_button') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="fixed inset-0 z-[210] flex items-center justify-center bg-slate-900/80 backdrop-blur">
|
||||
<div class="w-full max-w-4xl overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl ring-1 ring-black/10 dark:bg-slate-900/95">
|
||||
<div class="flex flex-col gap-6 p-6 sm:p-8">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">Ergebnis</p>
|
||||
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">{{ __('api.styled_image_display.title') }}</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-300">{{ __('api.styled_image_display.keep_hint') }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/20 bg-white/10 p-2 text-slate-900 shadow-sm transition hover:border-slate-300 hover:text-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-300 dark:text-white"
|
||||
@click="$emit('close')"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'xmark']" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-2xl border border-slate-200/60 bg-slate-50 dark:border-white/10 dark:bg-white/5">
|
||||
<img :src="image.path" alt="Styled Image" class="mx-auto max-h-[60vh] w-full object-contain" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-2xl border border-slate-200 bg-white px-5 py-2.5 text-sm font-semibold text-slate-800 shadow-sm transition hover:border-emerald-300 hover:text-emerald-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 dark:border-white/20 dark:bg-white/10 dark:text-white"
|
||||
@click="$emit('keep', image)"
|
||||
>
|
||||
{{ __('api.styled_image_display.keep_button') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!confirmingDelete"
|
||||
type="button"
|
||||
class="rounded-2xl border border-rose-200 bg-rose-50 px-5 py-2.5 text-sm font-semibold text-rose-700 shadow-sm transition hover:border-rose-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100"
|
||||
@click="confirmingDelete = true"
|
||||
>
|
||||
{{ __('api.styled_image_display.delete_button') }}
|
||||
</button>
|
||||
<div v-else class="flex items-center gap-2 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 shadow-sm dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
|
||||
<span>{{ __('api.styled_image_display.delete_confirm') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-rose-600 px-3 py-1 text-white shadow-sm transition hover:bg-rose-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
||||
@click="$emit('delete', image)"
|
||||
>
|
||||
{{ __('api.styled_image_display.delete_button') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-slate-200 px-3 py-1 text-slate-700 transition hover:border-slate-300 dark:border-white/20 dark:text-white"
|
||||
@click="confirmingDelete = false"
|
||||
>
|
||||
{{ __('settings.cancel_button') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
image: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
defineProps({
|
||||
image: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['keep', 'delete']);
|
||||
defineEmits(['keep', 'delete', 'close']);
|
||||
|
||||
console.log('StyledImageDisplay: image prop:', props.image);
|
||||
const confirmingDelete = ref(false);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any specific styles for the modal here if needed */
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user