finished the upgrade to filament 4. completely revamped the frontend with codex, now it looks great!

This commit is contained in:
2025-11-13 17:42:43 +01:00
parent f59fda588b
commit b311188bc1
138 changed files with 5440 additions and 4105 deletions

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>