Files
ai-stylegallery/resources/js/Pages/Home.vue
soeren f5da8ed877 - 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.
2025-12-04 07:52:50 +01:00

642 lines
20 KiB
Vue

<template>
<Head title="Start" />
<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="mx-auto flex w-full max-w-7xl flex-col gap-6 px-2 py-8 sm:px-4 lg:px-6">
<header class="rounded-3xl border border-slate-200 bg-white/90 p-6 text-slate-900 shadow-2xl backdrop-blur transition-colors duration-300 dark:border-white/10 dark:bg-white/5 dark:text-white">
<div class="flex flex-wrap items-start justify-between gap-6">
<div>
<p class="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">Live Gallery</p>
<h1 class="mt-1 text-3xl font-semibold text-slate-900 dark:text-white sm:text-4xl">
{{ props.galleryHeading }}
</h1>
</div>
<div class="flex flex-wrap items-center gap-3">
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 shadow-sm transition hover:border-emerald-400 hover:text-emerald-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 disabled:opacity-60 dark:border-white/20 dark:bg-white/10 dark:text-white dark:hover:text-emerald-200"
@click="handleManualRefresh"
:disabled="isRefreshing"
>
<font-awesome-icon :icon="['fas', 'arrows-rotate']" class="h-4 w-4" :class="{ 'animate-spin': isRefreshing }" />
<span>{{ isRefreshing ? 'Aktualisiere…' : 'Jetzt aktualisieren' }}</span>
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 shadow-sm transition hover:border-cyan-400 hover:text-cyan-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:text-cyan-200"
@click="toggleTheme"
>
<font-awesome-icon
:icon="currentTheme === 'light' ? ['fas', 'moon'] : ['fas', 'sun']"
class="h-4 w-4"
/>
<span>{{ currentTheme === 'light' ? __('api.dark_mode') : __('api.light_mode') }}</span>
</button>
<div
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>
<span>AI offline</span>
</div>
</div>
</div>
<div class="mt-4 flex flex-wrap items-center gap-4 text-xs text-slate-500 dark:text-slate-300">
<div class="inline-flex items-center gap-2 rounded-full bg-emerald-500/10 px-3 py-1 text-emerald-700 dark:text-emerald-200">
<span class="h-2 w-2 rounded-full bg-emerald-500 animate-pulse"></span>
Auto-Refresh · alle {{ refreshIntervalSeconds }}s
</div>
<div class="flex items-center gap-2">
<span class="h-1.5 w-1.5 rounded-full bg-slate-400"></span>
Letzte Synchronisierung: {{ formattedLastRefresh }}
</div>
<div class="flex items-center gap-2">
<font-awesome-icon :icon="['fas', 'bars']" class="h-4 w-4" />
Einfach über die Galerie wischen, um Seiten zu wechseln.
</div>
</div>
</header>
<section
class="rounded-3xl border border-slate-200 bg-white/80 p-3 shadow-2xl backdrop-blur transition-colors duration-300 touch-pan-y dark:border-white/5 dark:bg-white/5 sm:p-4"
@touchstart.passive="handleTouchStart"
@touchmove.passive="handleTouchMove"
@touchend="handleTouchEnd"
>
<GalleryGrid :images="paginatedImages" @imageTapped="showContextMenu" />
</section>
<Navigation :currentPage="currentPage" :totalPages="totalPages" @prevPage="prevPage" @nextPage="nextPage" />
</div>
<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"
@styleSelected="applyStyle"
/>
<StyledImageDisplay
v-if="currentOverlayComponent === 'styledImageDisplay' && styledImage"
:image="styledImage"
@keep="keepStyledImage"
@delete="deleteStyledImage"
/>
<LoadingSpinner v-if="isLoading" :progress="processingProgress" />
<PrintQuantityModal
v-if="currentOverlayComponent === 'printQuantityModal' && selectedImage"
@close="currentOverlayComponent = 'contextMenu'"
@printConfirmed="handlePrintConfirmed"
:maxCopies="maxCopiesSetting"
/>
<div
v-if="toastMessage"
:class="['fixed bottom-6 right-6 z-[120] rounded-2xl px-4 py-3 text-sm font-semibold text-white shadow-2xl', toastBackground]"
>
{{ toastMessage }}
</div>
</div>
</template>
<script setup>
import { Head, usePage } from '@inertiajs/vue3';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import axios from 'axios';
import GalleryGrid from '../Components/GalleryGrid.vue';
import ImageContextMenu from '../Components/ImageContextMenu.vue';
import Navigation from '../Components/Navigation.vue';
import PrintQuantityModal from '../Components/PrintQuantityModal.vue';
import StyledImageDisplay from '../Components/StyledImageDisplay.vue';
import LoadingSpinner from '../Components/LoadingSpinner.vue';
const props = defineProps({
galleryHeading: {
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);
const currentOverlayComponent = ref(null);
const selectedImage = ref(null);
const styledImage = ref(null);
const aiAvailable = ref(true);
const processingProgress = ref(0);
const isLoading = ref(false);
const currentTheme = ref('light');
const maxCopiesSetting = ref(10);
const isRefreshing = ref(false);
const lastRefreshedAt = ref(new Date());
const refreshIntervalMs = ref(5000);
const toastMessage = ref(null);
const toastVariant = ref('info');
let toastTimer = null;
let refreshTimer = null;
let aiStatusTimer = null;
const getImageIdentifier = (image) => image?.id ?? image?.image_id ?? null;
const extractFilenameFromHeader = (disposition) => {
if (!disposition) {
return null;
}
const filenameMatch = disposition.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i);
if (!filenameMatch) {
return null;
}
const encodedName = filenameMatch[1] || filenameMatch[2];
try {
return decodeURIComponent(encodedName);
} catch (error) {
return encodedName;
}
};
watch(
() => props.images,
(newImages) => {
if (Array.isArray(newImages)) {
images.value = newImages;
lastRefreshedAt.value = new Date();
}
},
{ immediate: true }
);
watch(
() => images.value.length,
() => {
if (currentPage.value > totalPages.value) {
currentPage.value = totalPages.value || 1;
}
}
);
const totalPages = computed(() => {
return Math.max(1, Math.ceil(images.value.length / imagesPerPage));
});
const paginatedImages = computed(() => {
const start = (currentPage.value - 1) * imagesPerPage;
return images.value.slice(start, start + imagesPerPage);
});
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',
minute: '2-digit',
second: '2-digit',
}).format(lastRefreshedAt.value)
);
const toastBackground = computed(() => {
if (toastVariant.value === 'error') {
return 'bg-rose-500/90';
}
if (toastVariant.value === 'success') {
return 'bg-emerald-500/90';
}
return 'bg-slate-800/90';
});
const showToast = (message, variant = 'error') => {
toastMessage.value = message;
toastVariant.value = variant;
if (toastTimer) {
clearTimeout(toastTimer);
}
toastTimer = setTimeout(() => {
toastMessage.value = null;
}, 4000);
};
const applyTheme = (theme) => {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(theme);
localStorage.setItem('theme', theme);
currentTheme.value = theme;
};
const toggleTheme = () => {
const newTheme = currentTheme.value === 'light' ? 'dark' : 'light';
applyTheme(newTheme);
};
const checkAiStatus = () => {
if (!allowAiStyles.value) {
aiAvailable.value = false;
return;
}
axios
.get('/api/ai-status', {
params: { gallery: gallerySlug.value },
})
.then((response) => {
aiAvailable.value = response.data.some((provider) => provider.available);
})
.catch((error) => {
console.error('Error checking AI status:', error);
aiAvailable.value = false;
});
};
const closeOverlays = () => {
currentOverlayComponent.value = null;
selectedImage.value = null;
};
const fetchImages = (options = { silent: false }) => {
if (!options.silent) {
isRefreshing.value = true;
}
axios
.get('/api/images', {
params: { gallery: gallerySlug.value },
})
.then((response) => {
if (Array.isArray(response.data)) {
images.value = response.data;
lastRefreshedAt.value = new Date();
}
})
.catch((error) => {
console.error('Error fetching images:', error);
showToast('Die Galerie konnte nicht aktualisiert werden.', 'error');
})
.finally(() => {
if (!options.silent) {
isRefreshing.value = false;
}
});
};
const startAutoRefresh = (intervalMs) => {
refreshIntervalMs.value = intervalMs;
if (refreshTimer) {
clearInterval(refreshTimer);
}
refreshTimer = setInterval(() => fetchImages({ silent: true }), intervalMs);
};
const handleManualRefresh = () => {
fetchImages({ silent: false });
};
const showContextMenu = (image) => {
selectedImage.value = image;
currentOverlayComponent.value = 'contextMenu';
};
const printImage = () => {
if (!selectedImage.value || !showPrintButton.value) {
return;
}
currentOverlayComponent.value = 'printQuantityModal';
};
const downloadImage = (imageParam = null) => {
const image = imageParam || selectedImage.value;
if (!image) {
return;
}
showToast('Download wird vorbereitet…', 'info');
axios
.post(
'/api/download-image',
{ image_path: image.path, gallery: gallerySlug.value },
{ responseType: 'blob' }
)
.then((response) => {
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
const disposition = response.headers?.['content-disposition'];
const filenameFromHeader = extractFilenameFromHeader(disposition);
const fallbackName = image.name || `stylegallery_${new Date().toISOString().replace(/[:.]/g, '-')}`;
link.href = url;
link.download = filenameFromHeader || fallbackName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
showToast('Download gestartet!', 'success');
})
.catch((error) => {
console.error('Error downloading image:', error);
showToast(error.response?.data?.error || 'Download fehlgeschlagen.', 'error');
});
};
const handlePrintConfirmed = (quantity) => {
const identifier = getImageIdentifier(selectedImage.value);
if (!identifier) {
showToast('Kein Bild ausgewählt.', 'error');
return;
}
axios
.post('/api/print-image', {
image_id: identifier,
image_path: selectedImage.value.path,
quantity,
gallery: gallerySlug.value,
})
.then((response) => {
console.log('Print request sent successfully:', response.data);
showToast('Druckauftrag wurde übermittelt.', 'success');
})
.catch((error) => {
console.error('Error sending print request:', error);
showToast(error.response?.data?.error || 'Druckauftrag fehlgeschlagen.', 'error');
})
.finally(() => {
currentOverlayComponent.value = null;
selectedImage.value = null;
});
};
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');
return;
}
console.log('Applying style:', style.title, 'to image:', targetImageId);
currentOverlayComponent.value = null;
isLoading.value = true;
processingProgress.value = 0;
axios
.post('/api/images/style-change', {
image_id: targetImageId,
style_id: style.id,
gallery: gallerySlug.value,
})
.then((response) => {
const promptId = response.data.prompt_id;
const plugin = response.data.plugin;
if (plugin === 'ComfyUi') {
axios
.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`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === 'progress') {
const { value, max } = message.data;
const progress = max > 0 ? (value / max) * 100 : 0;
if (message.data.prompt_id === promptId) {
processingProgress.value = progress;
if (processingProgress.value >= 100) {
axios
.get(`/api/images/fetch-styled/${promptId}`, {
params: { gallery: gallerySlug.value },
})
.then((imageResponse) => {
styledImage.value = imageResponse.data.styled_image;
currentOverlayComponent.value = 'styledImageDisplay';
fetchImages({ silent: true });
})
.catch((imageError) => {
console.error('Frontend: Error fetching styled image:', imageError.response?.data?.error || imageError.message);
showToast(imageError.response?.data?.error || 'Failed to fetch styled image.', 'error');
})
.finally(() => {
isLoading.value = false;
processingProgress.value = 0;
ws.close();
});
}
}
}
};
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
showToast('WebSocket connection error.', 'error');
isLoading.value = false;
processingProgress.value = 0;
};
ws.onclose = () => {
console.log('WebSocket closed.');
};
})
.catch((error) => {
console.error('Error fetching ComfyUI URL:', error);
showToast(error.response?.data?.error || 'Failed to get ComfyUI URL.', 'error');
isLoading.value = false;
});
} else {
const pollForStyledImage = () => {
axios
.get(`/api/images/fetch-styled/${promptId}`, {
params: { gallery: gallerySlug.value },
})
.then((imageResponse) => {
styledImage.value = imageResponse.data.styled_image;
currentOverlayComponent.value = 'styledImageDisplay';
fetchImages({ silent: true });
isLoading.value = false;
processingProgress.value = 0;
})
.catch((imageError) => {
if (imageError.response?.status === 404) {
if (imageError.response?.data?.progress !== undefined) {
processingProgress.value = imageError.response.data.progress;
}
setTimeout(pollForStyledImage, 2000);
} else {
showToast(imageError.response?.data?.error || 'Failed to fetch styled image.', 'error');
isLoading.value = false;
processingProgress.value = 0;
}
});
};
pollForStyledImage();
}
})
.catch((error) => {
console.error('Error applying style:', error);
showToast(error.response?.data?.error || 'Failed to apply style.', 'error');
isLoading.value = false;
processingProgress.value = 0;
});
};
const keepStyledImage = () => {
currentOverlayComponent.value = null;
styledImage.value = null;
};
const deleteStyledImage = () => {
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 = () => {
if (currentPage.value > 1) {
currentPage.value--;
}
};
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++;
}
};
const touchStartX = ref(0);
const touchCurrentX = ref(0);
const swipeThreshold = 60;
const handleTouchStart = (event) => {
if (!event.touches.length) {
return;
}
touchStartX.value = event.touches[0].clientX;
touchCurrentX.value = touchStartX.value;
};
const handleTouchMove = (event) => {
if (!event.touches.length) {
return;
}
touchCurrentX.value = event.touches[0].clientX;
};
const handleTouchEnd = (event) => {
const endX = event.changedTouches?.[0]?.clientX ?? touchCurrentX.value;
const delta = endX - touchStartX.value;
if (Math.abs(delta) > swipeThreshold) {
if (delta < 0) {
nextPage();
} else {
prevPage();
}
}
};
onMounted(() => {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
applyTheme(savedTheme);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
applyTheme('dark');
} else {
applyTheme('light');
}
axios
.get('/api/image-refresh-interval', {
params: { gallery: gallerySlug.value },
})
.then((response) => {
startAutoRefresh(response.data.interval * 1000);
})
.catch((error) => {
console.error('Error fetching image refresh interval:', error);
startAutoRefresh(5000);
});
axios
.get('/api/max-copies-setting', {
params: { gallery: gallerySlug.value },
})
.then((response) => {
maxCopiesSetting.value = response.data.max_copies;
})
.catch((error) => {
console.error('Error fetching max copies setting:', error);
});
checkAiStatus();
aiStatusTimer = setInterval(() => checkAiStatus(), 300000);
});
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
}
if (toastTimer) {
clearTimeout(toastTimer);
}
if (aiStatusTimer) {
clearInterval(aiStatusTimer);
}
});
</script>