finished the upgrade to filament 4. completely revamped the frontend with codex, now it looks great!
This commit is contained in:
@@ -1,8 +1,36 @@
|
||||
@import '@fortawesome/fontawesome-svg-core/styles.css';
|
||||
@import 'tailwindcss';
|
||||
@import '@fortawesome/fontawesome-svg-core/styles.css' layer(base);
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@plugin '@tailwindcss/forms';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../resources/js/**/*.vue';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
Figtree, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
|
||||
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme variables */
|
||||
:root {
|
||||
@@ -20,4 +48,4 @@ body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-text);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,6 @@ const proxyChecked = computed({
|
||||
type="checkbox"
|
||||
:value="value"
|
||||
v-model="proxyChecked"
|
||||
class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500"
|
||||
class="rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:ring-indigo-500"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150"
|
||||
class="inline-flex items-center px-4 py-2 bg-red-600 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-red-500 active:bg-red-700 focus:outline-hidden focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition ease-in-out duration-150"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
@@ -12,7 +12,7 @@ defineProps({
|
||||
<template>
|
||||
<Link
|
||||
:href="href"
|
||||
class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 transition duration-150 ease-in-out"
|
||||
class="block w-full px-4 py-2 text-start text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 transition duration-150 ease-in-out"
|
||||
>
|
||||
<slot />
|
||||
</Link>
|
||||
|
||||
@@ -1,15 +1,41 @@
|
||||
<template>
|
||||
<div class="gallery-grid">
|
||||
<div class="grid">
|
||||
<div
|
||||
class="grid-item"
|
||||
v-for="(image) in images"
|
||||
:key="image.name"
|
||||
<div class="space-y-4">
|
||||
<p
|
||||
v-if="!images.length"
|
||||
class="rounded-3xl border border-dashed border-white/20 bg-white/5 px-4 py-10 text-center text-sm text-slate-300"
|
||||
>
|
||||
{{ props.translations.empty_gallery || 'Noch keine Bilder vorhanden.' }}
|
||||
</p>
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
>
|
||||
<button
|
||||
v-for="image in images"
|
||||
:key="image.id ?? image.name"
|
||||
type="button"
|
||||
class="group relative aspect-[3/4] overflow-hidden rounded-[1.75rem] border border-white/10 bg-slate-900/30 shadow-2xl ring-1 ring-white/5 transition-all hover:border-emerald-300 focus:outline-none focus-visible:ring-4 focus-visible:ring-emerald-400"
|
||||
@click="$emit('imageTapped', image, $event)"
|
||||
:aria-label="fallbackLabel(image)"
|
||||
>
|
||||
<img :src="image.path" :alt="'Image ' + image.name" />
|
||||
<div v-if="image.is_new" class="new-badge">{{ __('new') }}</div>
|
||||
</div>
|
||||
<img
|
||||
:src="image.path"
|
||||
:alt="fallbackLabel(image)"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
||||
/>
|
||||
<span
|
||||
v-if="image.is_new"
|
||||
class="absolute left-4 top-4 inline-flex items-center gap-2 rounded-full bg-emerald-300/90 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-emerald-900 shadow-lg"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-emerald-800"></span>
|
||||
{{ __('new') }}
|
||||
</span>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent p-4 text-left">
|
||||
<p class="text-base font-semibold text-white drop-shadow">{{ fallbackLabel(image) }}</p>
|
||||
<p class="text-xs uppercase tracking-[0.3em] text-white/70">Tap to open</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,50 +59,6 @@ const emits = defineEmits(['imageTapped']);
|
||||
const __ = (key) => {
|
||||
return props.translations[key] || key;
|
||||
};
|
||||
|
||||
const fallbackLabel = (image) => image?.name || props.translations.image || 'Bild';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gallery-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr); /* 4 Spalten */
|
||||
gap: 20px; /* Zwischenabstand */
|
||||
width: 100%;
|
||||
max-width: 1200px; /* Beispiel: Maximale Breite des Rasters */
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
position: relative; /* Added for badge positioning */
|
||||
}
|
||||
|
||||
.grid-item img {
|
||||
width: 100%;
|
||||
height: 200px; /* Feste Höhe für die Bilder im Raster */
|
||||
object-fit: cover; /* Bilder zuschneiden, um den Bereich zu füllen */
|
||||
display: block;
|
||||
}
|
||||
|
||||
.new-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: red;
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
@@ -1,135 +1,181 @@
|
||||
<template>
|
||||
<div class="context-menu-overlay" @click.self="$emit('close')">
|
||||
<div class="context-menu" :style="{ top: `${position.y}px`, left: `${position.x}px` }">
|
||||
<div class="context-menu-image-preview">
|
||||
<img :src="image.path" :alt="'Selected Image ' + image.name" />
|
||||
</div>
|
||||
<div class="context-menu-options">
|
||||
<div v-if="!showStyleSelectorView" class="flex flex-col p-2 space-y-2">
|
||||
<button @click="$emit('close')" class="context-menu-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-10 h-10 inline-block align-middle mr-2">
|
||||
<path fill-rule="evenodd" d="M5.47 5.47a.75.75 0 0 1 1.06 0L12 10.94l5.47-5.47a.75.75 0 1 1 1.06 1.06L13.06 12l5.47 5.47a.75.75 0 1 1-1.06 1.06L12 13.06l-5.47 5.47a.75.75 0 0 1-1.06-1.06L10.94 12 5.47 6.53a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
||||
<path d="M6.22 6.22a.75.75 0 0 1 1.06 0L12 10.94l4.72-4.72a.75.75 0 1 1 1.06 1.06L13.06 12l4.72 4.72a.75.75 0 1 1-1.06 1.06L12 13.06l-4.72 4.72a.75.75 0 0 1-1.06-1.06L10.94 12 6.22 7.28a.75.75 0 0 1 0-1.06Z" />
|
||||
</svg>
|
||||
Schließen
|
||||
</button>
|
||||
<button v-if="settings.show_print_button === '1'" @click="$emit('print', image)" class="context-menu-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor" class="w-8 h-8 inline-block align-middle mr-2"><!--!Font Awesome Free v6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M128 0C92.7 0 64 28.7 64 64l0 96 64 0 0-96 226.7 0L384 93.3l0 66.7 64 0 0-66.7c0-17-6.7-33.3-18.7-45.3L400 18.7C388 6.7 371.7 0 354.7 0L128 0zM384 352l0 32 0 64-256 0 0-64 0-16 0-16 256 0zm64 32l32 0c17.7 0 32-14.3 32-32l0-96c0-35.3-28.7-64-64-64L64 192c-35.3 0-64 28.7-64 64l0 96c0 17.7 14.3 32 32 32l32 0 0 64c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-64zM432 248a24 24 0 1 1 0 48 24 24 0 1 1 0-48z"/></svg>
|
||||
Drucken
|
||||
</button>
|
||||
<button @click="showStyleSelectorView = true" class="context-menu-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor" class="w-8 h-8 inline-block align-middle mr-2"><!--!Font Awesome Pro v6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc.--><path d="M256 48a208 208 0 1 1 0 416 208 208 0 1 1 0-416zm0 464A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM183.2 132.6c-1.3-2.8-4.1-4.6-7.2-4.6s-5.9 1.8-7.2 4.6l-16.6 34.7-38.1 5c-3.1 .4-5.6 2.5-6.6 5.5s-.1 6.2 2.1 8.3l27.9 26.5-7 37.8c-.6 3 .7 6.1 3.2 7.9s5.8 2 8.5 .6L176 240.5l33.8 18.3c2.7 1.5 6 1.3 8.5-.6s3.7-4.9 3.2-7.9l-7-37.8L242.4 186c2.2-2.1 3.1-5.3 2.1-8.3s-3.5-5.1-6.6-5.5l-38.1-5-16.6-34.7zm160 0c-1.3-2.8-4.1-4.6-7.2-4.6s-5.9 1.8-7.2 4.6l-16.6 34.7-38.1 5c-3.1 .4-5.6 2.5-6.6 5.5s-.1 6.2 2.1 8.3l27.9 26.5-7 37.8c-.6 3 .7 6.1 3.2 7.9s5.8 2 8.5 .6L336 240.5l33.8 18.3c2.7 1.5 6 1.3 8.5-.6s3.7-4.9 3.2-7.9l-7-37.8L402.4 186c2.2-2.1 3.1-5.3 2.1-8.3s-3.5-5.1-6.6-5.5l-38.1-5-16.6-34.7zm6.3 175.8c-28.9 6.8-60.5 10.5-93.6 10.5s-64.7-3.7-93.6-10.5c-18.7-4.4-35.9 12-25.5 28.1c24.6 38.1 68.7 63.5 119.1 63.5s94.5-25.4 119.1-63.5c10.4-16.1-6.8-32.5-25.5-28.1z"/></svg>
|
||||
Stil ändern
|
||||
</button>
|
||||
<button v-if="isNonLocalAccess()" @click="$emit('download', image)" class="context-menu-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor" class="w-8 h-8 inline-block align-middle mr-2"><!--!Font Awesome Free v6.7.2 by @fontawesome - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M288 32c0-17.7-14.3-32-32-32s-32 14.3-32 32V240c0 8.8-7.2 16-16 16s-16-7.2-16-16V64.7c0-5.4-1.2-10.7-3.2-15.7l-8.4-8.4c-2.3-2.3-2.3-6.1 0-8.5s6.1-2.3 8.5 0l8.4 8.4c5.3 5.3 11.4 8.6 18.2 9.7L338.6 59.2c11.4 1.9 23.5 2.9 35.4 2.9s24-1 35.4-2.9l48.6-9.1c6.8-1.1 12.9-4.4 18.2-9.7l8.4-8.4c2.3-2.3 6.1-2.3 8.5 0s2.3 6.1 0 8.5l-8.4 8.4c-2 5-3.2 10.3-3.2 15.7V240c0 8.8-7.2 16-16 16s-16-7.2-16-16V32zm-32 264.3c-43.7-5.5-79.7-30.9-90.7-69.7H128c-5.4 0-9.8-4.4-9.8-9.8s4.4-9.8 9.8-9.8h128c8.8 0 16 7.2 16 16s-7.2 16-16 16H133.3c18.1 30.9 55.4 56.9 100.7 56.9s82.6-26 100.7-56.9H384c8.8 0 16 7.2 16 16s-7.2 16-16 16H128c-5.4 0-9.8-4.4-9.8-9.8s4.4-9.8 9.8-9.8h136.3c-11 38.8-47 64.2-90.7 69.7z"/></svg>
|
||||
Herunterladen
|
||||
</button>
|
||||
<div class="fixed inset-0 z-[110] flex items-center justify-center bg-slate-950/80 px-4 backdrop-blur-lg" @click.self="$emit('close')">
|
||||
<div class="w-full max-w-6xl overflow-hidden rounded-[2.5rem] border border-white/10 bg-white/95 text-slate-900 shadow-[0_45px_85px_rgba(15,23,42,0.45)] ring-1 ring-slate-200 transition dark:bg-slate-900/95 dark:text-white">
|
||||
<div class="flex flex-col gap-6 lg:flex-row">
|
||||
<figure class="relative flex-1 overflow-hidden bg-slate-900/80">
|
||||
<img
|
||||
v-if="image?.path"
|
||||
:src="image.path"
|
||||
:alt="imageLabel"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
<div v-else class="flex h-full min-h-[320px] items-center justify-center text-slate-500">
|
||||
Kein Bild geladen
|
||||
</div>
|
||||
<div class="pointer-events-none absolute inset-x-0 top-0 flex items-center justify-between p-6 text-xs uppercase tracking-[0.4em] text-white/70">
|
||||
<span>{{ image?.name || 'Ausgewähltes Bild' }}</span>
|
||||
<span>{{ image?.id }}</span>
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<div class="flex w-full flex-col gap-6 p-6 lg:w-[360px]">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.4em] text-slate-400">Menü</p>
|
||||
<h2 class="mt-1 text-2xl font-semibold leading-tight">{{ imageLabel }}</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 truncate">
|
||||
{{ image?.path }}
|
||||
</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-rose-400 hover:text-rose-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400 dark:text-white"
|
||||
@click="$emit('close')"
|
||||
aria-label="Kontextmenü schließen"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m6 6 12 12M6 18 18 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!showStyleSelectorView" class="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border border-white/20 bg-white/40 px-4 py-3 text-left font-semibold text-slate-900 transition hover:border-emerald-400 hover:bg-white/70 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||
@click="showStyleSelectorView = true"
|
||||
>
|
||||
<div>
|
||||
<p class="text-base">Stile anzeigen</p>
|
||||
<p class="text-sm font-normal text-slate-500 dark:text-slate-400">Lass die KI dein Motiv verzaubern.</p>
|
||||
</div>
|
||||
<span class="flex h-12 w-12 items-center justify-center rounded-full bg-white/60 text-slate-900 shadow-md dark:bg-slate-800/70 dark:text-white">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904a2.25 2.25 0 0 1 0-3.808l.99-.572a1.125 1.125 0 0 0 0-1.948l-.99-.572a2.25 2.25 0 0 1 0-3.808l5.25-3.038A2.25 2.25 0 0 1 17.625 3v18a2.25 2.25 0 0 1-3.563 1.844l-4.249-2.457Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.25a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0v-12a.75.75 0 0 1 .75-.75Zm13.5 0a.75.75 0 0 1 .75.75v12a.75.75 0 0 1-1.5 0v-12a.75.75 0 0 1 .75-.75Z" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="showPrintButton"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border border-white/10 bg-slate-900/5 px-4 py-3 text-left font-semibold text-slate-900 transition hover:border-cyan-300 hover:bg-cyan-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||
@click="$emit('print', image)"
|
||||
>
|
||||
<div>
|
||||
<p class="text-base">Drucken</p>
|
||||
<p class="text-sm font-normal text-slate-500 dark:text-slate-400">Sofort den Druckdialog öffnen</p>
|
||||
</div>
|
||||
<span class="flex h-12 w-12 items-center justify-center rounded-full bg-cyan-100 text-cyan-900 shadow-md dark:bg-cyan-400/20 dark:text-cyan-200">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 9V4.5A1.5 1.5 0 0 1 7.5 3h9A1.5 1.5 0 0 1 18 4.5V9m-12 0h12m-12 0H4.5A1.5 1.5 0 0 0 3 10.5v6A1.5 1.5 0 0 0 4.5 18H6m12-9h1.5A1.5 1.5 0 0 1 21 10.5v6a1.5 1.5 0 0 1-1.5 1.5H18m-12 0v4.5A1.5 1.5 0 0 0 7.5 24h9a1.5 1.5 0 0 0 1.5-1.5V18m-12 0h12" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="shouldShowDownload"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border border-white/10 bg-slate-900/5 px-4 py-3 text-left font-semibold text-slate-900 transition hover:border-indigo-300 hover:bg-indigo-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||
@click="$emit('download', image)"
|
||||
>
|
||||
<div>
|
||||
<p class="text-base">Herunterladen</p>
|
||||
<p class="text-sm font-normal text-slate-500 dark:text-slate-400">Datei lokal speichern</p>
|
||||
</div>
|
||||
<span class="flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-indigo-900 shadow-md dark:bg-indigo-400/20 dark:text-indigo-200">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v12m0 0 4-4m-4 4-4-4m-5 7.5h18" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border border-white/10 bg-white/0 px-4 py-3 text-left font-semibold text-slate-900 transition hover:border-rose-300 hover:bg-rose-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<div>
|
||||
<p class="text-base">Schließen</p>
|
||||
<p class="text-sm font-normal text-slate-500 dark:text-slate-400">Zurück zur Galerie</p>
|
||||
</div>
|
||||
<span class="flex h-12 w-12 items-center justify-center rounded-full bg-rose-100 text-rose-900 shadow-md dark:bg-rose-400/20 dark:text-rose-200">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div class="flex items-center justify-between text-sm text-slate-400">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-white/20 px-3 py-1 text-xs uppercase tracking-[0.3em] text-white/80 transition hover:border-emerald-300"
|
||||
@click="showStyleSelectorView = false"
|
||||
>
|
||||
<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="m10 19-7-7 7-7m-7 7h18" />
|
||||
</svg>
|
||||
Zurück
|
||||
</button>
|
||||
<span>Stilauswahl</span>
|
||||
</div>
|
||||
<StyleSelector
|
||||
class="max-h-[520px] flex-1"
|
||||
:image_id="image?.id ?? image?.image_id"
|
||||
@styleSelected="handleStyleSelected"
|
||||
@close="$emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StyleSelector
|
||||
v-else
|
||||
:image_id="image.image_id"
|
||||
@styleSelected="(style, imageId) => $emit('styleSelected', style, imageId)"
|
||||
@back="showStyleSelectorView = false"
|
||||
@close="$emit('close')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import StyleSelector from './StyleSelector.vue';
|
||||
|
||||
const page = usePage();
|
||||
const settings = page.props.settings;
|
||||
|
||||
const props = defineProps({
|
||||
position: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
image: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['close', 'print', 'changeStyle', 'styleSelected', 'download']);
|
||||
const emits = defineEmits(['close', 'print', 'styleSelected', 'download']);
|
||||
|
||||
const isNonLocalAccess = () => {
|
||||
const settings = computed(() => page.props.settings || {});
|
||||
const showPrintButton = computed(() => settings.value.show_print_button ?? true);
|
||||
|
||||
const shouldShowDownload = computed(() => {
|
||||
const hostname = window.location.hostname;
|
||||
return hostname !== 'localhost' &&
|
||||
hostname !== '127.0.0.1' &&
|
||||
!hostname.startsWith('192.168.') &&
|
||||
!hostname.startsWith('10.') &&
|
||||
!hostname.startsWith('172.');
|
||||
};
|
||||
hostname !== '127.0.0.1' &&
|
||||
!hostname.startsWith('192.168.') &&
|
||||
!hostname.startsWith('10.') &&
|
||||
!hostname.startsWith('172.');
|
||||
});
|
||||
|
||||
const showStyleSelectorView = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.image,
|
||||
() => {
|
||||
showStyleSelectorView.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const imageLabel = computed(() => props.image?.name || 'Ausgewähltes Bild');
|
||||
|
||||
const handleStyleSelected = (style, imageId) => {
|
||||
showStyleSelectorView.value = false;
|
||||
emits('styleSelected', style, imageId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.context-menu-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5); /* Halbdurchsichtiger Hintergrund */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-text);
|
||||
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: row; /* Bild und Menü nebeneinander */
|
||||
max-width: 90%;
|
||||
height: 80vh; /* 80% of viewport height */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.context-menu-image-preview {
|
||||
flex: 7; /* Takes 60% of the space (6 out of 10 parts) */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.context-menu-image-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: 80%; /* 80% width of its container */
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.context-menu-options {
|
||||
flex: 3; /* Takes 40% of the space (4 out of 10 parts) */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-left: 1px solid var(--color-text); /* Trennlinie zwischen Bild und Menü */
|
||||
min-width: 150px; /* Mindestbreite für das Menü */
|
||||
}
|
||||
|
||||
.context-menu-button {
|
||||
@apply flex items-center justify-start px-4 py-3 font-medium text-white bg-[#0056b3] hover:bg-[#004494] focus:outline-none focus:ring-2 focus:ring-[#0056b3] focus:ring-offset-2 transition ease-in-out duration-150 rounded-md;
|
||||
width: 70%;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-text);
|
||||
margin: 5px auto;
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
.context-menu-button:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,8 +14,8 @@ const props = defineProps({
|
||||
|
||||
const classes = computed(() =>
|
||||
props.active
|
||||
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-none focus:border-indigo-700 transition duration-150 ease-in-out'
|
||||
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-none focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out'
|
||||
? 'inline-flex items-center px-1 pt-1 border-b-2 border-indigo-400 text-sm font-medium leading-5 text-gray-900 focus:outline-hidden focus:border-indigo-700 transition duration-150 ease-in-out'
|
||||
: 'inline-flex items-center px-1 pt-1 border-b-2 border-transparent text-sm font-medium leading-5 text-gray-500 hover:text-gray-700 hover:border-gray-300 focus:outline-hidden focus:text-gray-700 focus:border-gray-300 transition duration-150 ease-in-out'
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,37 @@
|
||||
<template>
|
||||
<nav class="navigation">
|
||||
<button @click="$emit('prevPage')" :disabled="currentPage === 1">{{ __('navigation.previous') }}</button>
|
||||
<span>{{ __('navigation.page_of', { currentPage: currentPage, totalPages: totalPages }) }}</span>
|
||||
<button @click="$emit('nextPage')" :disabled="currentPage === totalPages">{{ __('navigation.next') }}</button>
|
||||
<nav class="rounded-3xl border border-white/10 bg-white/5 p-5 text-white shadow-2xl backdrop-blur">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 text-xs uppercase tracking-[0.4em] text-slate-300">
|
||||
Seite
|
||||
<span class="text-xl font-semibold text-white">{{ currentPage }}</span>
|
||||
<span class="text-slate-500">/</span>
|
||||
<span class="text-lg text-slate-200">{{ totalPages }}</span>
|
||||
</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-cyan-300 hover:text-cyan-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300 disabled:opacity-50"
|
||||
@click="$emit('prevPage')"
|
||||
:disabled="currentPage === 1"
|
||||
>
|
||||
<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="m15.75 19.5-7.5-7.5 7.5-7.5" />
|
||||
</svg>
|
||||
{{ __('navigation.previous') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-emerald-300 bg-emerald-400/20 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-400/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 disabled:opacity-50"
|
||||
@click="$emit('nextPage')"
|
||||
:disabled="currentPage === totalPages"
|
||||
>
|
||||
{{ __('navigation.next') }}
|
||||
<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.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -20,38 +49,3 @@ const props = defineProps({
|
||||
|
||||
const emits = defineEmits(['prevPage', 'nextPage']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navigation {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background-color: var(--color-background);
|
||||
width: 100%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.navigation button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
margin: 0 10px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.navigation button:disabled {
|
||||
background-color: #cccccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.navigation span {
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150"
|
||||
class="inline-flex items-center px-4 py-2 bg-gray-800 border border-transparent rounded-md font-semibold text-xs text-white uppercase tracking-widest hover:bg-gray-700 focus:bg-gray-700 active:bg-gray-900 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition ease-in-out duration-150"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
@@ -1,24 +1,69 @@
|
||||
<template>
|
||||
<div class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal-content">
|
||||
<h2 class="text-xl font-bold mb-4">{{ __('api.print_dialog.title') }}</h2>
|
||||
<p class="mb-4">{{ __('api.print_dialog.quantity_prompt') }}</p>
|
||||
|
||||
<div class="flex items-center justify-center space-x-4 mb-6">
|
||||
<button @click="decrementQuantity" class="quantity-button">
|
||||
-
|
||||
</button>
|
||||
<span class="text-5xl font-bold">{{ quantity }}</span>
|
||||
<button @click="incrementQuantity" class="quantity-button">
|
||||
+
|
||||
<div class="fixed inset-0 z-[120] flex items-center justify-center bg-slate-950/70 px-6 py-10 backdrop-blur" @click.self="$emit('close')">
|
||||
<div class="w-full max-w-md rounded-[2.5rem] border border-white/10 bg-gradient-to-b from-slate-900 via-slate-900/90 to-slate-950 p-8 text-white shadow-[0_35px_80px_rgba(2,6,23,0.65)]">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-[0.5em] text-slate-400">{{ __('api.print_dialog.title') }}</p>
|
||||
<h2 class="mt-2 text-3xl font-semibold text-white">{{ __('api.print_dialog.quantity_prompt') }}</h2>
|
||||
<p class="mt-2 text-sm text-slate-300">
|
||||
Maximale Ausgabe pro Auftrag:
|
||||
<span class="font-semibold text-emerald-300">{{ maxCopies }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/10 p-2 text-slate-200 transition hover:border-rose-300 hover:text-rose-300"
|
||||
@click="$emit('close')"
|
||||
:aria-label="__('api.print_dialog.cancel_button')"
|
||||
>
|
||||
<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="M6 18 18 6m-12 0 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-2">
|
||||
<button @click="$emit('close')" class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">
|
||||
<div class="mt-8 flex flex-col gap-6">
|
||||
<div class="rounded-3xl border border-white/10 bg-white/5 p-6 text-center text-slate-200">
|
||||
<p class="text-sm uppercase tracking-[0.4em] text-slate-400">Anzahl</p>
|
||||
<div class="mt-4 flex items-center justify-center gap-6">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/20 bg-white/10 text-3xl font-semibold text-white transition hover:border-emerald-300 hover:text-emerald-200"
|
||||
@click="decrementQuantity"
|
||||
aria-label="-1"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<div class="min-w-[4rem] text-6xl font-bold tracking-tight text-white">{{ quantity }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/20 bg-white/10 text-3xl font-semibold text-white transition hover:border-emerald-300 hover:text-emerald-200"
|
||||
@click="incrementQuantity"
|
||||
aria-label="+1"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs uppercase tracking-[0.4em] text-slate-400">
|
||||
Du steuerst direkt den Sofortdruck.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-full border border-white/20 bg-white/5 px-5 py-3 text-center text-sm font-semibold uppercase tracking-[0.2em] text-slate-200 transition hover:border-rose-300 hover:text-rose-200"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
{{ __('api.print_dialog.cancel_button') }}
|
||||
</button>
|
||||
<button @click="confirmPrint" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-full border border-emerald-300 bg-emerald-400/20 px-5 py-3 text-center text-sm font-semibold uppercase tracking-[0.2em] text-emerald-100 transition hover:bg-emerald-400/40"
|
||||
@click="confirmPrint"
|
||||
>
|
||||
{{ __('api.print_dialog.print_button') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -32,7 +77,7 @@ import { ref, watch } from 'vue';
|
||||
const props = defineProps({
|
||||
maxCopies: {
|
||||
type: Number,
|
||||
default: 10, // Default max if not provided
|
||||
default: 10,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -56,40 +101,12 @@ const confirmPrint = () => {
|
||||
emit('printConfirmed', quantity.value);
|
||||
};
|
||||
|
||||
// Watch for changes in maxCopies prop and adjust quantity if it exceeds new max
|
||||
watch(() => props.maxCopies, (newMax) => {
|
||||
if (quantity.value > newMax) {
|
||||
quantity.value = newMax;
|
||||
watch(
|
||||
() => props.maxCopies,
|
||||
(newMax) => {
|
||||
if (quantity.value > newMax) {
|
||||
quantity.value = newMax;
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--color-background);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.quantity-button {
|
||||
@apply bg-blue-500 text-white rounded-full w-16 h-16 flex items-center justify-center text-4xl font-bold;
|
||||
@apply hover:bg-blue-600 transition-colors duration-200;
|
||||
}
|
||||
</style>
|
||||
@@ -14,8 +14,8 @@ const props = defineProps({
|
||||
|
||||
const classes = computed(() =>
|
||||
props.active
|
||||
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-none focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out'
|
||||
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-none focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out'
|
||||
? 'block w-full ps-3 pe-4 py-2 border-l-4 border-indigo-400 text-start text-base font-medium text-indigo-700 bg-indigo-50 focus:outline-hidden focus:text-indigo-800 focus:bg-indigo-100 focus:border-indigo-700 transition duration-150 ease-in-out'
|
||||
: 'block w-full ps-3 pe-4 py-2 border-l-4 border-transparent text-start text-base font-medium text-gray-600 hover:text-gray-800 hover:bg-gray-50 hover:border-gray-300 focus:outline-hidden focus:text-gray-800 focus:bg-gray-50 focus:border-gray-300 transition duration-150 ease-in-out'
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ defineProps({
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 transition ease-in-out duration-150"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-gray-300 rounded-md font-semibold text-xs text-gray-700 uppercase tracking-widest shadow-xs hover:bg-gray-50 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-25 transition ease-in-out duration-150"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
@@ -1,162 +1,129 @@
|
||||
<template>
|
||||
<div class="style-selector">
|
||||
<div class="style-selector-header">
|
||||
<span class="back-arrow" @click="$emit('back')">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
|
||||
<path fill-rule="evenodd" d="M11.03 4.272a.75.75 0 0 1 0 1.06L6.31 10.5H20.25a.75.75 0 0 1 0 1.5H6.31l4.72 5.168a.75.75 0 0 1-1.06 1.06l-6-6a.75.75 0 0 1 0-1.06l6-6a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<h3>Verfügbare Stile</h3>
|
||||
</div>
|
||||
<div class="styles-list">
|
||||
<div
|
||||
v-for="style in styles"
|
||||
:key="style.id"
|
||||
class="style-item"
|
||||
@click="selectStyle(style)"
|
||||
<div class="flex h-full flex-col gap-4 rounded-3xl border border-white/10 bg-white/5 p-4 text-white">
|
||||
<div class="flex items-center justify-between gap-2 text-xs uppercase tracking-[0.3em] text-slate-300">
|
||||
<span>Varianten</span>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-white/20 p-1 text-white/70 transition hover:border-rose-300 hover:text-rose-300"
|
||||
@click="$emit('close')"
|
||||
aria-label="Auswahl schließen"
|
||||
>
|
||||
<img :data-src="'/storage/' + style.preview_image" :alt="style.title" class="style-thumbnail" />
|
||||
<div class="style-details">
|
||||
<h4>{{ style.title }}</h4>
|
||||
<p>{{ style.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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="M6 18 18 6m-12 0 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="stylesContainer" class="flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<p v-if="isLoading" class="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-center text-sm text-slate-300">
|
||||
Stile werden geladen …
|
||||
</p>
|
||||
<p v-else-if="loadError" class="rounded-2xl border border-rose-400/40 bg-rose-500/10 px-4 py-3 text-center text-sm text-rose-100">
|
||||
{{ loadError }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="style in styles"
|
||||
:key="style.id"
|
||||
type="button"
|
||||
class="group flex w-full items-center gap-4 rounded-2xl border border-white/10 bg-white/5 p-3 text-left transition hover:border-emerald-300"
|
||||
@click="selectStyle(style)"
|
||||
>
|
||||
<img
|
||||
v-if="style.preview_image"
|
||||
:data-src="'/storage/' + style.preview_image"
|
||||
class="h-24 w-24 flex-none rounded-2xl object-cover opacity-90 transition group-hover:opacity-100"
|
||||
alt="Style preview"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-24 w-24 flex-none items-center justify-center rounded-2xl border border-dashed border-white/20 text-[0.65rem] uppercase tracking-[0.4em] text-slate-400"
|
||||
>
|
||||
AI
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<p class="text-base font-semibold text-white">{{ style.title }}</p>
|
||||
<p class="text-sm text-slate-200">{{ style.description }}</p>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import axios from 'axios';
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
|
||||
|
||||
const styles = ref([]);
|
||||
const isLoading = ref(true);
|
||||
const loadError = ref(null);
|
||||
const stylesContainer = ref(null);
|
||||
let observer = null;
|
||||
|
||||
const props = defineProps({
|
||||
image_id: {
|
||||
type: Number,
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['styleSelected', 'back', 'close']);
|
||||
const emits = defineEmits(['styleSelected', 'close']);
|
||||
|
||||
const hydrateObserver = () => {
|
||||
if (!stylesContainer.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries, obs) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
obs.unobserve(img);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ root: stylesContainer.value }
|
||||
);
|
||||
|
||||
stylesContainer.value.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));
|
||||
};
|
||||
|
||||
const fetchStyles = () => {
|
||||
axios.get('/api/styles')
|
||||
.then(response => {
|
||||
isLoading.value = true;
|
||||
loadError.value = null;
|
||||
axios
|
||||
.get('/api/styles')
|
||||
.then((response) => {
|
||||
styles.value = response.data;
|
||||
nextTick(() => {
|
||||
const stylesList = document.querySelector('.styles-list');
|
||||
const observer = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
img.src = img.dataset.src;
|
||||
observer.unobserve(img);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
root: stylesList // Observe intersections relative to .styles-list
|
||||
});
|
||||
|
||||
stylesList.querySelectorAll('.style-thumbnail').forEach(img => {
|
||||
observer.observe(img);
|
||||
});
|
||||
});
|
||||
nextTick(hydrateObserver);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('Error fetching styles:', error);
|
||||
loadError.value = 'Stile konnten nicht geladen werden.';
|
||||
})
|
||||
.finally(() => {
|
||||
isLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const selectStyle = (style) => {
|
||||
console.log('StyleSelector.vue: emitting styleSelected with image_id:', props.image_id);
|
||||
emits('styleSelected', style, props.image_id);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchStyles();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.style-selector {
|
||||
@apply bg-white dark:bg-gray-800 !important;
|
||||
@apply border border-gray-300 dark:border-gray-700;
|
||||
@apply shadow-lg dark:shadow-none;
|
||||
@apply text-gray-900 dark:text-white !important;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%; /* Adjusted to fit parent */
|
||||
max-height: 100%; /* Adjusted to fit parent */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.style-selector-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.style-selector-header h3 {
|
||||
margin: 0;
|
||||
@apply dark:text-white;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.back-arrow {
|
||||
position: absolute;
|
||||
left: 5px; /* Adjusted position */
|
||||
font-size: 2em; /* Slightly larger */
|
||||
cursor: pointer;
|
||||
padding: 10px; /* Larger clickable area */
|
||||
color: var(--color-text); /* Adapt to theme */
|
||||
}
|
||||
|
||||
.back-arrow:hover {
|
||||
color: var(--color-background); /* Adjust hover color for dark mode */
|
||||
background: var(--color-text); /* Adjust hover background for dark mode */
|
||||
}
|
||||
|
||||
.styles-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.style-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.style-item:hover {
|
||||
background-color: #eef;
|
||||
}
|
||||
|
||||
.style-thumbnail {
|
||||
width: 120px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.style-details h4 {
|
||||
margin: 0;
|
||||
@apply text-blue-600 dark:text-white;
|
||||
}
|
||||
|
||||
.style-details p {
|
||||
margin: 5px 0 0;
|
||||
font-size: 0.9em;
|
||||
@apply text-gray-600 dark:text-gray-400;
|
||||
}
|
||||
</style>
|
||||
@@ -6,13 +6,13 @@
|
||||
<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-none focus:ring-2 focus:ring-green-500 focus:ring-opacity-50"
|
||||
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"
|
||||
>
|
||||
{{ __('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-none focus:ring-2 focus:ring-red-500 focus:ring-opacity-50"
|
||||
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"
|
||||
>
|
||||
{{ __('styled_image_display.delete_button') }}
|
||||
</button>
|
||||
|
||||
@@ -19,7 +19,7 @@ defineExpose({ focus: () => input.value.focus() });
|
||||
|
||||
<template>
|
||||
<input
|
||||
class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
|
||||
class="border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-xs"
|
||||
v-model="model"
|
||||
ref="input"
|
||||
/>
|
||||
|
||||
@@ -65,7 +65,7 @@ function handleTouchEnd(event) {
|
||||
<span class="inline-flex rounded-md">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-hidden transition ease-in-out duration-150"
|
||||
>
|
||||
{{ $page.props.auth.user.name }}
|
||||
|
||||
@@ -99,7 +99,7 @@ function handleTouchEnd(event) {
|
||||
<div class="-me-2 flex items-center sm:hidden">
|
||||
<button
|
||||
@click="showingNavigationDropdown = !showingNavigationDropdown"
|
||||
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out"
|
||||
class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-hidden focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out"
|
||||
>
|
||||
<svg class="h-6 w-6" stroke="currentColor" fill="none" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -159,7 +159,7 @@ function handleTouchEnd(event) {
|
||||
</nav>
|
||||
|
||||
<!-- Page Heading -->
|
||||
<header class="bg-white shadow" v-if="$slots.header">
|
||||
<header class="bg-white shadow-sm" v-if="$slots.header">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@ const submit = () => {
|
||||
<Link
|
||||
v-if="canResetPassword"
|
||||
:href="route('password.request')"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
|
||||
@@ -89,7 +89,7 @@ const submit = () => {
|
||||
<div class="flex items-center justify-end mt-4">
|
||||
<Link
|
||||
:href="route('login')"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Already registered?
|
||||
</Link>
|
||||
|
||||
@@ -42,7 +42,7 @@ const verificationLinkSent = computed(() => props.status === 'verification-link-
|
||||
:href="route('logout')"
|
||||
method="post"
|
||||
as="button"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>Log Out</Link
|
||||
>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Head } from '@inertiajs/vue3';
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
|
||||
<div class="bg-white overflow-hidden shadow-xs sm:rounded-lg">
|
||||
<div class="p-6 text-gray-900">You're logged in!</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,109 +1,226 @@
|
||||
<template>
|
||||
<Head title="Start" />
|
||||
<div class="home">
|
||||
<div class="main-content">
|
||||
<div class="gallery-container" @touchstart="handleTouchStart" @touchend="handleTouchEnd">
|
||||
<h1 class="text-2xl font-bold text-center my-4">{{ props.galleryHeading }}</h1>
|
||||
<div class="absolute top-4 right-4">
|
||||
<button @click="toggleTheme" class="theme-toggle-button">
|
||||
{{ currentTheme === 'light' ? __('api.dark_mode') : __('api.light_mode') }}
|
||||
</button>
|
||||
<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" />
|
||||
<Navigation
|
||||
:currentPage="currentPage"
|
||||
:totalPages="totalPages"
|
||||
@prevPage="prevPage"
|
||||
@nextPage="nextPage"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Navigation :currentPage="currentPage" :totalPages="totalPages" @prevPage="prevPage" @nextPage="nextPage" />
|
||||
</div>
|
||||
|
||||
<ImageContextMenu
|
||||
v-if="currentOverlayComponent === 'contextMenu'"
|
||||
:position="contextMenuPosition"
|
||||
v-if="currentOverlayComponent === 'contextMenu' && selectedImage"
|
||||
:image="selectedImage"
|
||||
@close="currentOverlayComponent = null; selectedImage = null"
|
||||
@close="closeOverlays"
|
||||
@print="printImage"
|
||||
@download="downloadImage"
|
||||
@changeStyle="showStyleSelector"
|
||||
@styleSelected="applyStyle"
|
||||
/>
|
||||
|
||||
<StyleSelector
|
||||
v-if="currentOverlayComponent === 'styleSelector'"
|
||||
:image_id="selectedImage.id"
|
||||
@styleSelected="applyStyle"
|
||||
@back="goBackToContextMenu"
|
||||
@close="currentOverlayComponent = null; selectedImage = null"
|
||||
/>
|
||||
|
||||
<div v-if="errorMessage" class="fixed bottom-4 right-4 bg-red-500 text-white p-4 rounded-lg shadow-lg z-50">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<StyledImageDisplay
|
||||
v-if="currentOverlayComponent === 'styledImageDisplay'"
|
||||
v-if="currentOverlayComponent === 'styledImageDisplay' && styledImage"
|
||||
:image="styledImage"
|
||||
@keep="keepStyledImage"
|
||||
@delete="deleteStyledImage"
|
||||
/>
|
||||
|
||||
<LoadingSpinner v-if="isLoading" :progress="processingProgress" />
|
||||
|
||||
<PrintQuantityModal
|
||||
v-if="currentOverlayComponent === 'printQuantityModal'"
|
||||
@close="currentOverlayComponent = null"
|
||||
v-if="currentOverlayComponent === 'printQuantityModal' && selectedImage"
|
||||
@close="currentOverlayComponent = 'contextMenu'"
|
||||
@printConfirmed="handlePrintConfirmed"
|
||||
:maxCopies="maxCopiesSetting"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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 { defineProps } from 'vue';
|
||||
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: String,
|
||||
translations: Object,
|
||||
galleryHeading: {
|
||||
type: String,
|
||||
default: 'Gallery',
|
||||
},
|
||||
translations: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
images: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const images = ref([]);
|
||||
let fetchInterval = null;
|
||||
|
||||
const fetchImages = () => {
|
||||
axios.get('/api/images')
|
||||
.then(response => {
|
||||
images.value = response.data;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching images:', error);
|
||||
showError(error.response?.data?.error || 'An unknown error occurred.');
|
||||
});
|
||||
};
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import Navigation from '../Components/Navigation.vue';
|
||||
import GalleryGrid from '../Components/GalleryGrid.vue';
|
||||
import ImageContextMenu from '../Components/ImageContextMenu.vue';
|
||||
import StyleSelector from '../Components/StyleSelector.vue';
|
||||
import StyledImageDisplay from '../Components/StyledImageDisplay.vue'; // Import the new component
|
||||
import LoadingSpinner from '../Components/LoadingSpinner.vue'; // Import the new component
|
||||
import PrintQuantityModal from '../Components/PrintQuantityModal.vue'; // Import the new component
|
||||
import axios from 'axios';
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const imagesPerPage = 12;
|
||||
const currentPage = ref(1);
|
||||
const currentOverlayComponent = ref(null); // null, 'contextMenu', 'styleSelector', 'styledImageDisplay'
|
||||
const contextMenuPosition = ref({ x: 0, y: 0 });
|
||||
const currentOverlayComponent = ref(null);
|
||||
const selectedImage = ref(null);
|
||||
const styledImage = ref(null); // To store the newly styled image
|
||||
const processingProgress = ref(0); // To store the progress percentage
|
||||
const errorMessage = ref(null); // New ref for error messages
|
||||
const isLoading = ref(false); // New ref for loading state
|
||||
const currentTheme = ref('light'); // New ref for current theme
|
||||
const maxCopiesSetting = ref(10); // Default to 10, will be fetched from backend
|
||||
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;
|
||||
|
||||
let touchStartX = 0;
|
||||
let touchEndX = 0;
|
||||
const getImageIdentifier = (image) => image?.id ?? image?.image_id ?? null;
|
||||
|
||||
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');
|
||||
@@ -117,231 +234,240 @@ const toggleTheme = () => {
|
||||
applyTheme(newTheme);
|
||||
};
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(images.value.length / imagesPerPage);
|
||||
});
|
||||
|
||||
const paginatedImages = computed(() => {
|
||||
const start = (currentPage.value - 1) * imagesPerPage;
|
||||
const end = start + imagesPerPage;
|
||||
return images.value.slice(start, end);
|
||||
});
|
||||
|
||||
const showError = (message) => {
|
||||
errorMessage.value = message;
|
||||
setTimeout(() => {
|
||||
errorMessage.value = null;
|
||||
}, 5000); // Clear error after 5 seconds
|
||||
const closeOverlays = () => {
|
||||
currentOverlayComponent.value = null;
|
||||
selectedImage.value = null;
|
||||
};
|
||||
|
||||
const showContextMenu = (image, event) => {
|
||||
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;
|
||||
contextMenuPosition.value = { x: event.clientX, y: event.clientY };
|
||||
currentOverlayComponent.value = 'contextMenu';
|
||||
};
|
||||
|
||||
const printImage = () => {
|
||||
console.log('Showing print quantity modal for image:', selectedImage.value);
|
||||
if (!selectedImage.value) {
|
||||
return;
|
||||
}
|
||||
currentOverlayComponent.value = 'printQuantityModal';
|
||||
};
|
||||
|
||||
const downloadImage = (image) => {
|
||||
console.log('Starting download for image:', image);
|
||||
|
||||
// Show loading message
|
||||
showError('Download wird vorbereitet...');
|
||||
|
||||
// Use axios to make a POST request to trigger backend download
|
||||
axios.post('/api/download-image', {
|
||||
image_path: image.path,
|
||||
}, {
|
||||
responseType: 'blob' // Important for file downloads
|
||||
})
|
||||
.then(response => {
|
||||
// Create blob URL from response
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// Create temporary link and trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = image.name || 'image';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up blob URL
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
showError('Download gestartet!');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error downloading image:', error);
|
||||
showError(error.response?.data?.error || 'Download fehlgeschlagen.');
|
||||
});
|
||||
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');
|
||||
link.href = url;
|
||||
link.download = image.name || 'image';
|
||||
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) => {
|
||||
console.log(`Printing ${quantity} copies of image:`, selectedImage.value);
|
||||
currentOverlayComponent.value = null; // Close the modal
|
||||
const identifier = getImageIdentifier(selectedImage.value);
|
||||
if (!identifier) {
|
||||
showToast('Kein Bild ausgewählt.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
axios.post('/api/print-image', {
|
||||
image_id: selectedImage.value.id,
|
||||
image_path: selectedImage.value.path,
|
||||
quantity: quantity,
|
||||
})
|
||||
.then(response => {
|
||||
console.log('Print request sent successfully:', response.data);
|
||||
showError('Print request sent successfully!');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error sending print request:', error);
|
||||
showError(error.response?.data?.error || 'Failed to send print request.');
|
||||
});
|
||||
};
|
||||
|
||||
const showStyleSelector = () => {
|
||||
currentOverlayComponent.value = 'styleSelector';
|
||||
};
|
||||
|
||||
const goBackToContextMenu = () => {
|
||||
currentOverlayComponent.value = 'contextMenu';
|
||||
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) => {
|
||||
console.log('Applying style:', style.title, 'to image:', imageId);
|
||||
currentOverlayComponent.value = null; // Close style selector immediately
|
||||
isLoading.value = true; // Show loading spinner
|
||||
processingProgress.value = 0; // Reset progress
|
||||
const targetImageId = imageId ?? getImageIdentifier(selectedImage.value);
|
||||
if (!targetImageId) {
|
||||
showToast('Kein Bild ausgewählt.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send style change request to backend first
|
||||
axios.post('/api/images/style-change', {
|
||||
image_id: imageId,
|
||||
style_id: style.id,
|
||||
})
|
||||
.then(response => {
|
||||
console.log('Style change request sent:', response.data);
|
||||
// Store the prompt_id and plugin from the backend response
|
||||
const promptId = response.data.prompt_id;
|
||||
const plugin = response.data.plugin;
|
||||
console.log('Applying style:', style.title, 'to image:', targetImageId);
|
||||
currentOverlayComponent.value = null;
|
||||
isLoading.value = true;
|
||||
processingProgress.value = 0;
|
||||
|
||||
// Handle different plugins differently
|
||||
if (plugin === 'ComfyUi') {
|
||||
// For ComfyUI, use WebSocket for progress monitoring
|
||||
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);
|
||||
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;
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected to ComfyUI.');
|
||||
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.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === 'progress') {
|
||||
console.log('ComfyUI Progress Message:', message);
|
||||
const { value, max } = message.data;
|
||||
const progress = (max > 0) ? (value / max) * 100 : 0;
|
||||
if (message.data.prompt_id === promptId) {
|
||||
processingProgress.value = progress;
|
||||
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) {
|
||||
console.log('Frontend: Progress reached 100%. Attempting to fetch final image.', { promptId: promptId });
|
||||
// Fetch the final styled image from the backend
|
||||
axios.get(`/api/images/fetch-styled/${promptId}`)
|
||||
.then(imageResponse => {
|
||||
console.log('Frontend: Successfully fetched styled image.', imageResponse.data);
|
||||
styledImage.value = imageResponse.data.styled_image;
|
||||
currentOverlayComponent.value = 'styledImageDisplay';
|
||||
fetchImages(); // Refresh gallery
|
||||
})
|
||||
.catch(imageError => {
|
||||
console.error('Frontend: Error fetching styled image:', imageError.response?.data?.error || imageError.message);
|
||||
showError(imageError.response?.data?.error || 'Failed to fetch styled image.');
|
||||
})
|
||||
.finally(() => {
|
||||
console.log('Frontend: Final fetch process completed.');
|
||||
isLoading.value = false;
|
||||
processingProgress.value = 0;
|
||||
ws.close();
|
||||
});
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('Received unexpected WebSocket message type:', message.type, message);
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
showError('WebSocket connection error.');
|
||||
isLoading.value = false;
|
||||
processingProgress.value = 0;
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket closed.');
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching ComfyUI URL:', error);
|
||||
showError(error.response?.data?.error || 'Failed to get ComfyUI URL.');
|
||||
isLoading.value = false;
|
||||
});
|
||||
} else {
|
||||
// For other plugins, use polling approach
|
||||
const pollForStyledImage = () => {
|
||||
axios.get(`/api/images/fetch-styled/${promptId}`)
|
||||
.then(imageResponse => {
|
||||
console.log('Frontend: Successfully fetched styled image.', imageResponse.data);
|
||||
styledImage.value = imageResponse.data.styled_image;
|
||||
currentOverlayComponent.value = 'styledImageDisplay';
|
||||
fetchImages(); // Refresh gallery
|
||||
isLoading.value = false;
|
||||
processingProgress.value = 0;
|
||||
})
|
||||
.catch(imageError => {
|
||||
console.error('Frontend: Error fetching styled image:', imageError.response?.data?.error || imageError.message);
|
||||
// If the image is not ready yet, continue polling
|
||||
if (imageError.response?.status === 404) {
|
||||
// Update progress if available
|
||||
if (imageError.response?.data?.progress !== undefined) {
|
||||
processingProgress.value = imageError.response.data.progress;
|
||||
}
|
||||
// Continue polling
|
||||
setTimeout(pollForStyledImage, 2000); // Poll every 2 seconds
|
||||
} else {
|
||||
showError(imageError.response?.data?.error || 'Failed to fetch styled image.');
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
// Start polling for the styled image
|
||||
pollForStyledImage();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error applying style:', error);
|
||||
showError(error.response?.data?.error || 'Failed to apply style.');
|
||||
isLoading.value = false;
|
||||
processingProgress.value = 0;
|
||||
});
|
||||
} 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 = (imageToKeep) => {
|
||||
console.log('Keeping styled image:', imageToKeep);
|
||||
// Implement API call to mark image as kept/permanent if needed
|
||||
currentOverlayComponent.value = null; // Close the display
|
||||
const keepStyledImage = () => {
|
||||
currentOverlayComponent.value = null;
|
||||
styledImage.value = null;
|
||||
};
|
||||
|
||||
const deleteStyledImage = (imageToDelete) => {
|
||||
console.log('Deleting styled image:', imageToDelete);
|
||||
// Implement API call to delete the temporary styled image
|
||||
currentOverlayComponent.value = null; // Close the display
|
||||
const deleteStyledImage = () => {
|
||||
currentOverlayComponent.value = null;
|
||||
styledImage.value = null;
|
||||
};
|
||||
|
||||
const prevPage = () => {
|
||||
@@ -356,28 +482,39 @@ const nextPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const touchStartX = ref(0);
|
||||
const touchCurrentX = ref(0);
|
||||
const swipeThreshold = 60;
|
||||
|
||||
const handleTouchStart = (event) => {
|
||||
touchStartX = event.touches[0].clientX;
|
||||
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) => {
|
||||
touchEndX = event.changedTouches[0].clientX;
|
||||
handleSwipeGesture();
|
||||
};
|
||||
const endX = event.changedTouches?.[0]?.clientX ?? touchCurrentX.value;
|
||||
const delta = endX - touchStartX.value;
|
||||
|
||||
const handleSwipeGesture = () => {
|
||||
const swipeThreshold = 50; // Minimum distance for a swipe
|
||||
if (touchEndX < touchStartX - swipeThreshold) {
|
||||
// Swiped left
|
||||
nextPage();
|
||||
} else if (touchEndX > touchStartX + swipeThreshold) {
|
||||
// Swiped right
|
||||
prevPage();
|
||||
if (Math.abs(delta) > swipeThreshold) {
|
||||
if (delta < 0) {
|
||||
nextPage();
|
||||
} else {
|
||||
prevPage();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Apply theme from localStorage on mount
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme) {
|
||||
applyTheme(savedTheme);
|
||||
@@ -387,84 +524,32 @@ onMounted(() => {
|
||||
applyTheme('light');
|
||||
}
|
||||
|
||||
fetchImages();
|
||||
|
||||
// Fetch image refresh interval from API
|
||||
axios.get('/api/image-refresh-interval')
|
||||
.then(response => {
|
||||
const interval = response.data.interval * 1000;
|
||||
fetchInterval = setInterval(fetchImages, interval);
|
||||
axios
|
||||
.get('/api/image-refresh-interval')
|
||||
.then((response) => {
|
||||
startAutoRefresh(response.data.interval * 1000);
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('Error fetching image refresh interval:', error);
|
||||
fetchInterval = setInterval(fetchImages, 5000); // Fallback to 5 seconds
|
||||
startAutoRefresh(5000);
|
||||
});
|
||||
|
||||
// Fetch max number of copies setting from API
|
||||
axios.get('/api/max-copies-setting')
|
||||
.then(response => {
|
||||
axios
|
||||
.get('/api/max-copies-setting')
|
||||
.then((response) => {
|
||||
maxCopiesSetting.value = response.data.max_copies;
|
||||
})
|
||||
.catch(error => {
|
||||
.catch((error) => {
|
||||
console.error('Error fetching max copies setting:', error);
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(fetchInterval);
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
}
|
||||
if (toastTimer) {
|
||||
clearTimeout(toastTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.gallery-container {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.theme-toggle-button {
|
||||
margin-top: 10px;
|
||||
padding: 8px 15px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.theme-toggle-button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.image-visibility-toggle-button {
|
||||
margin-top: 10px;
|
||||
padding: 8px 15px;
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.image-visibility-toggle-button:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
</style>
|
||||
@@ -25,7 +25,7 @@ defineProps({
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<UpdateProfileInformationForm
|
||||
:must-verify-email="mustVerifyEmail"
|
||||
:status="status"
|
||||
@@ -33,11 +33,11 @@ defineProps({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<UpdatePasswordForm class="max-w-xl" />
|
||||
</div>
|
||||
|
||||
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
|
||||
<div class="p-4 sm:p-8 bg-white shadow-sm sm:rounded-lg">
|
||||
<DeleteUserForm class="max-w-xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,7 @@ const form = useForm({
|
||||
:href="route('verification.send')"
|
||||
method="post"
|
||||
as="button"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Click here to re-send the verification email.
|
||||
</Link>
|
||||
|
||||
@@ -29,21 +29,21 @@ defineProps({
|
||||
<Link
|
||||
v-if="$page.props.auth.user"
|
||||
:href="route('dashboard')"
|
||||
class="font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Dashboard</Link
|
||||
>
|
||||
|
||||
<template v-else>
|
||||
<Link
|
||||
:href="route('login')"
|
||||
class="font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Log in</Link
|
||||
>
|
||||
|
||||
<Link
|
||||
v-if="canRegister"
|
||||
:href="route('register')"
|
||||
class="ms-4 font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="ms-4 font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Register</Link
|
||||
>
|
||||
</template>
|
||||
@@ -68,7 +68,7 @@ defineProps({
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
|
||||
<a
|
||||
href="https://laravel.com/docs"
|
||||
class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500"
|
||||
class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-linear-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline-solid focus:outline-2 focus:outline-red-500"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
@@ -115,7 +115,7 @@ defineProps({
|
||||
|
||||
<a
|
||||
href="https://laracasts.com"
|
||||
class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500"
|
||||
class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-linear-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline-solid focus:outline-2 focus:outline-red-500"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
@@ -161,7 +161,7 @@ defineProps({
|
||||
|
||||
<a
|
||||
href="https://laravel-news.com"
|
||||
class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500"
|
||||
class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-linear-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline-solid focus:outline-2 focus:outline-red-500"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
@@ -207,7 +207,7 @@ defineProps({
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500"
|
||||
class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-linear-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline-solid focus:outline-2 focus:outline-red-500"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
@@ -234,54 +234,54 @@ defineProps({
|
||||
Laravel's robust library of first-party tools and libraries, such as
|
||||
<a
|
||||
href="https://forge.laravel.com"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Forge</a
|
||||
>,
|
||||
<a
|
||||
href="https://vapor.laravel.com"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Vapor</a
|
||||
>,
|
||||
<a
|
||||
href="https://nova.laravel.com"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Nova</a
|
||||
>, and
|
||||
<a
|
||||
href="https://envoyer.io"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Envoyer</a
|
||||
>
|
||||
help you take your projects to the next level. Pair them with powerful open source
|
||||
libraries like
|
||||
<a
|
||||
href="https://laravel.com/docs/billing"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Cashier</a
|
||||
>,
|
||||
<a
|
||||
href="https://laravel.com/docs/dusk"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Dusk</a
|
||||
>,
|
||||
<a
|
||||
href="https://laravel.com/docs/broadcasting"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Echo</a
|
||||
>,
|
||||
<a
|
||||
href="https://laravel.com/docs/horizon"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Horizon</a
|
||||
>,
|
||||
<a
|
||||
href="https://laravel.com/docs/sanctum"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Sanctum</a
|
||||
>,
|
||||
<a
|
||||
href="https://laravel.com/docs/telescope"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500"
|
||||
class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500"
|
||||
>Telescope</a
|
||||
>, and more.
|
||||
</p>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<x-filament-panels::page>
|
||||
<form wire:submit="save">
|
||||
{{ $this->form }}
|
||||
|
||||
<x-filament-panels::form.actions
|
||||
:actions="$this->getFormActions()"
|
||||
/>
|
||||
<div class="fi-form-actions">
|
||||
<x-filament::actions :actions="$this->getFormActions()" />
|
||||
</div>
|
||||
</form>
|
||||
</x-filament-panels::page>
|
||||
</x-filament-panels::page>
|
||||
|
||||
@@ -1,64 +1,3 @@
|
||||
<x-filament-panels::page>
|
||||
<x-filament::section>
|
||||
<x-slot name="heading">
|
||||
Plugins
|
||||
</x-slot>
|
||||
|
||||
<x-filament-tables::table>
|
||||
<thead>
|
||||
<tr>
|
||||
<x-filament-tables::header-cell>
|
||||
Name
|
||||
</x-filament-tables::header-cell>
|
||||
<x-filament-tables::header-cell>
|
||||
Identifier
|
||||
</x-filament-tables::header-cell>
|
||||
<x-filament-tables::header-cell>
|
||||
Enabled
|
||||
</x-filament-tables::header-cell>
|
||||
<x-filament-tables::header-cell>
|
||||
Configured
|
||||
</x-filament-tables::header-cell>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($plugins as $plugin)
|
||||
<x-filament-tables::row>
|
||||
<x-filament-tables::cell>
|
||||
{{ $plugin->name }}
|
||||
</x-filament-tables::cell>
|
||||
<x-filament-tables::cell>
|
||||
{{ $plugin->identifier }}
|
||||
</x-filament-tables::cell>
|
||||
<x-filament-tables::cell>
|
||||
@if ($plugin->enabled)
|
||||
<x-filament::icon
|
||||
icon="heroicon-o-check-circle"
|
||||
class="text-success-500 w-5 h-5"
|
||||
/>
|
||||
@else
|
||||
<x-filament::icon
|
||||
icon="heroicon-o-x-circle"
|
||||
class="text-danger-500 w-5 h-5"
|
||||
/>
|
||||
@endif
|
||||
</x-filament-tables::cell>
|
||||
<x-filament-tables::cell>
|
||||
@if ($plugin->configured)
|
||||
<x-filament::icon
|
||||
icon="heroicon-o-check-circle"
|
||||
class="text-success-500 w-5 h-5"
|
||||
/>
|
||||
@else
|
||||
<x-filament::icon
|
||||
icon="heroicon-o-x-circle"
|
||||
class="text-danger-500 w-5 h-5"
|
||||
/>
|
||||
@endif
|
||||
</x-filament-tables::cell>
|
||||
</x-filament-tables::row>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</x-filament-tables::table>
|
||||
</x-filament::section>
|
||||
{{ $this->table }}
|
||||
</x-filament-panels::page>
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
@if (Route::has('login'))
|
||||
<div class="sm:fixed sm:top-0 sm:right-0 p-6 text-right">
|
||||
@auth
|
||||
<a href="{{ url('/home') }}" class="font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Home</a>
|
||||
<a href="{{ url('/home') }}" class="font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Home</a>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Log in</a>
|
||||
<a href="{{ route('login') }}" class="font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Log in</a>
|
||||
|
||||
@if (Route::has('register'))
|
||||
<a href="{{ route('register') }}" class="ml-4 font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Register</a>
|
||||
<a href="{{ route('register') }}" class="ml-4 font-semibold text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Register</a>
|
||||
@endif
|
||||
@endauth
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<div class="mt-16">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 lg:gap-8">
|
||||
<a href="https://laravel.com/docs" class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500">
|
||||
<a href="https://laravel.com/docs" class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-linear-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline-solid focus:outline-2 focus:outline-red-500">
|
||||
<div>
|
||||
<div class="h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="w-7 h-7 stroke-red-500">
|
||||
@@ -60,7 +60,7 @@
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a href="https://laracasts.com" class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500">
|
||||
<a href="https://laracasts.com" class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-linear-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline-solid focus:outline-2 focus:outline-red-500">
|
||||
<div>
|
||||
<div class="h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="w-7 h-7 stroke-red-500">
|
||||
@@ -80,7 +80,7 @@
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a href="https://laravel-news.com" class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500">
|
||||
<a href="https://laravel-news.com" class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-linear-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline-solid focus:outline-2 focus:outline-red-500">
|
||||
<div>
|
||||
<div class="h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="w-7 h-7 stroke-red-500">
|
||||
@@ -100,7 +100,7 @@
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<div class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-gradient-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline focus:outline-2 focus:outline-red-500">
|
||||
<div class="scale-100 p-6 bg-white dark:bg-gray-800/50 dark:bg-linear-to-bl from-gray-700/50 via-transparent dark:ring-1 dark:ring-inset dark:ring-white/5 rounded-lg shadow-2xl shadow-gray-500/20 dark:shadow-none flex motion-safe:hover:scale-[1.01] transition-all duration-250 focus:outline-solid focus:outline-2 focus:outline-red-500">
|
||||
<div>
|
||||
<div class="h-16 w-16 bg-red-50 dark:bg-red-800/20 flex items-center justify-center rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="w-7 h-7 stroke-red-500">
|
||||
@@ -111,7 +111,7 @@
|
||||
<h2 class="mt-6 text-xl font-semibold text-gray-900 dark:text-white">Vibrant Ecosystem</h2>
|
||||
|
||||
<p class="mt-4 text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
|
||||
Laravel's robust library of first-party tools and libraries, such as <a href="https://forge.laravel.com" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Forge</a>, <a href="https://vapor.laravel.com" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Vapor</a>, <a href="https://nova.laravel.com" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Nova</a>, and <a href="https://envoyer.io" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Envoyer</a> help you take your projects to the next level. Pair them with powerful open source libraries like <a href="https://laravel.com/docs/billing" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Cashier</a>, <a href="https://laravel.com/docs/dusk" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Dusk</a>, <a href="https://laravel.com/docs/broadcasting" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Echo</a>, <a href="https://laravel.com/docs/horizon" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Horizon</a>, <a href="https://laravel.com/docs/sanctum" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Sanctum</a>, <a href="https://laravel.com/docs/telescope" class="underline hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">Telescope</a>, and more.
|
||||
Laravel's robust library of first-party tools and libraries, such as <a href="https://forge.laravel.com" class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Forge</a>, <a href="https://vapor.laravel.com" class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Vapor</a>, <a href="https://nova.laravel.com" class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Nova</a>, and <a href="https://envoyer.io" class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Envoyer</a> help you take your projects to the next level. Pair them with powerful open source libraries like <a href="https://laravel.com/docs/billing" class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Cashier</a>, <a href="https://laravel.com/docs/dusk" class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Dusk</a>, <a href="https://laravel.com/docs/broadcasting" class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Echo</a>, <a href="https://laravel.com/docs/horizon" class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Horizon</a>, <a href="https://laravel.com/docs/sanctum" class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Sanctum</a>, <a href="https://laravel.com/docs/telescope" class="underline hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">Telescope</a>, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,7 +121,7 @@
|
||||
<div class="flex justify-center mt-16 px-0 sm:items-center sm:justify-between">
|
||||
<div class="text-center text-sm text-gray-500 dark:text-gray-400 sm:text-left">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="https://github.com/sponsors/taylorotwell" class="group inline-flex items-center hover:text-gray-700 dark:hover:text-white focus:outline focus:outline-2 focus:rounded-sm focus:outline-red-500">
|
||||
<a href="https://github.com/sponsors/taylorotwell" class="group inline-flex items-center hover:text-gray-700 dark:hover:text-white focus:outline-solid focus:outline-2 focus:rounded-xs focus:outline-red-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" class="-mt-px mr-1 w-5 h-5 stroke-gray-400 dark:stroke-gray-600 group-hover:stroke-gray-600 dark:group-hover:stroke-gray-400">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z" />
|
||||
</svg>
|
||||
|
||||
Reference in New Issue
Block a user