578 lines
18 KiB
Vue
578 lines
18 KiB
Vue
<template>
|
|
<Head title="Start" />
|
|
<div class="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-slate-100">
|
|
<div class="mx-auto flex w-full max-w-7xl flex-col gap-6 px-4 py-8 sm:px-6 lg:px-8">
|
|
<header class="rounded-3xl border border-white/10 bg-white/5 p-6 shadow-2xl backdrop-blur">
|
|
<div class="flex flex-wrap items-start justify-between gap-6">
|
|
<div>
|
|
<p class="text-xs uppercase tracking-[0.35em] text-slate-400">Live Gallery</p>
|
|
<h1 class="mt-2 text-3xl font-semibold text-white sm:text-4xl">
|
|
{{ props.galleryHeading }}
|
|
</h1>
|
|
<p class="mt-1 text-sm text-slate-400">Touch-friendly wall with the freshest images.</p>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/10 px-4 py-2 text-sm font-semibold text-white transition hover:border-emerald-400 hover:text-emerald-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 disabled:opacity-60"
|
|
@click="handleManualRefresh"
|
|
:disabled="isRefreshing"
|
|
>
|
|
<svg
|
|
class="h-4 w-4"
|
|
:class="{ 'animate-spin': isRefreshing }"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M16.023 9.348h4.992V4.355m0 0-2.852 2.853A8.25 8.25 0 0 0 4.5 12c0 1.007.176 1.973.5 2.869m2.977 4.784H3v4.993m0 0 2.853-2.853A8.25 8.25 0 0 0 19.5 12c0-1.007-.176-1.973-.5-2.869"
|
|
/>
|
|
</svg>
|
|
<span>{{ isRefreshing ? 'Aktualisiere…' : 'Jetzt aktualisieren' }}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm font-semibold text-white transition hover:border-cyan-300 hover:text-cyan-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400"
|
|
@click="toggleTheme"
|
|
>
|
|
<svg v-if="currentTheme === 'light'" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21.75 15A9.75 9.75 0 1 1 9 2.25 7.5 7.5 0 0 0 21.75 15Z" />
|
|
</svg>
|
|
<svg v-else class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-3.773-4.227-1.591 1.591M5.25 12H3m4.227-3.773L5.636 6.636M12 8.25a3.75 3.75 0 1 0 0 7.5 3.75 3.75 0 0 0 0-7.5Z"
|
|
/>
|
|
</svg>
|
|
<span>{{ currentTheme === 'light' ? __('api.dark_mode') : __('api.light_mode') }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="mt-4 flex flex-wrap items-center gap-4 text-xs text-slate-300">
|
|
<div class="inline-flex items-center gap-2 rounded-full bg-emerald-400/10 px-3 py-1 text-emerald-200">
|
|
<span class="h-2 w-2 rounded-full bg-emerald-400 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">
|
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5h7.5m-9 5.25h10.5M6 15h12m-9 5.25h6" />
|
|
</svg>
|
|
Einfach über die Galerie wischen, um Seiten zu wechseln.
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<section
|
|
class="rounded-3xl border border-white/5 bg-white/5 p-4 shadow-2xl backdrop-blur touch-pan-y"
|
|
@touchstart.passive="handleTouchStart"
|
|
@touchmove.passive="handleTouchMove"
|
|
@touchend="handleTouchEnd"
|
|
>
|
|
<GalleryGrid :images="paginatedImages" @imageTapped="showContextMenu" :translations="props.translations" />
|
|
</section>
|
|
|
|
<Navigation :currentPage="currentPage" :totalPages="totalPages" @prevPage="prevPage" @nextPage="nextPage" />
|
|
</div>
|
|
|
|
<ImageContextMenu
|
|
v-if="currentOverlayComponent === 'contextMenu' && selectedImage"
|
|
:image="selectedImage"
|
|
@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, router } 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',
|
|
},
|
|
translations: {
|
|
type: Object,
|
|
default: () => ({}),
|
|
},
|
|
images: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
});
|
|
|
|
const images = ref([]);
|
|
const imagesPerPage = 12;
|
|
const currentPage = ref(1);
|
|
const currentOverlayComponent = ref(null);
|
|
const selectedImage = ref(null);
|
|
const styledImage = ref(null);
|
|
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;
|
|
|
|
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 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 closeOverlays = () => {
|
|
currentOverlayComponent.value = null;
|
|
selectedImage.value = null;
|
|
};
|
|
|
|
const fetchImages = (options = { silent: false }) => {
|
|
if (!options.silent) {
|
|
isRefreshing.value = true;
|
|
}
|
|
|
|
router.reload({
|
|
only: ['images'],
|
|
preserveScroll: true,
|
|
onSuccess: (page) => {
|
|
if (Array.isArray(page.props.images)) {
|
|
images.value = page.props.images;
|
|
lastRefreshedAt.value = new Date();
|
|
}
|
|
},
|
|
onError: () => {
|
|
showToast('Die Galerie konnte nicht aktualisiert werden.', 'error');
|
|
},
|
|
onFinish: () => {
|
|
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) {
|
|
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 },
|
|
{ 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,
|
|
})
|
|
.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) => {
|
|
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,
|
|
})
|
|
.then((response) => {
|
|
const promptId = response.data.prompt_id;
|
|
const plugin = response.data.plugin;
|
|
|
|
if (plugin === 'ComfyUi') {
|
|
axios
|
|
.get(`/api/comfyui-url?style_id=${style.id}`)
|
|
.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}`)
|
|
.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}`)
|
|
.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 = () => {
|
|
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')
|
|
.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')
|
|
.then((response) => {
|
|
maxCopiesSetting.value = response.data.max_copies;
|
|
})
|
|
.catch((error) => {
|
|
console.error('Error fetching max copies setting:', error);
|
|
});
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (refreshTimer) {
|
|
clearInterval(refreshTimer);
|
|
}
|
|
if (toastTimer) {
|
|
clearTimeout(toastTimer);
|
|
}
|
|
});
|
|
</script>
|