Initial commit: spreewaldzeit + Dockerfile for Coolify (Next.js + Prisma/SQLite)

This commit is contained in:
2026-06-03 14:08:48 +02:00
committed by Ihor_Zhekov
commit bf5d79a919
94 changed files with 12480 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
export function Gallery({ images, alt }: { images: string[]; alt: string }) {
const [open, setOpen] = useState<number | null>(null);
const touchStartX = useRef<number | null>(null);
const thumbsRef = useRef<HTMLDivElement>(null);
// Keyboard + scroll lock
useEffect(() => {
if (open === null) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(null);
if (e.key === "ArrowRight") setOpen((i) => (i === null ? null : (i + 1) % images.length));
if (e.key === "ArrowLeft") setOpen((i) => (i === null ? null : (i - 1 + images.length) % images.length));
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, images.length]);
// Scroll active thumbnail into view
useEffect(() => {
if (open === null || !thumbsRef.current) return;
const btn = thumbsRef.current.children[open] as HTMLElement | undefined;
btn?.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
}, [open]);
if (images.length === 0) return null;
const [hero, ...rest] = images;
const go = (dir: 1 | -1) =>
setOpen((i) => (i === null ? null : (i + dir + images.length) % images.length));
return (
<>
{/* Grid */}
<div className="grid md:grid-cols-5 gap-2 md:gap-3">
<button
onClick={() => setOpen(0)}
className="md:col-span-3 relative aspect-[4/3] overflow-hidden rounded-sm group"
>
<Image
src={hero}
alt={`${alt} — Ansicht 1`}
fill
priority
sizes="(max-width: 768px) 100vw, 60vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.02]"
/>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span className="bg-ink/70 text-parchment text-xs px-3 py-1.5 rounded-full backdrop-blur-sm">
Alle {images.length} Fotos
</span>
</div>
</button>
<div className="md:col-span-2 grid grid-cols-2 gap-2 md:gap-3">
{rest.slice(0, 4).map((src, idx) => (
<button
key={src}
onClick={() => setOpen(idx + 1)}
className="relative aspect-[4/3] overflow-hidden rounded-sm group"
>
<Image
src={src}
alt={`${alt} — Ansicht ${idx + 2}`}
fill
sizes="(max-width: 768px) 50vw, 25vw"
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
/>
{idx === 3 && images.length > 5 && (
<div className="absolute inset-0 bg-ink/55 text-parchment flex items-center justify-center text-sm font-medium">
+{images.length - 5}
</div>
)}
</button>
))}
</div>
</div>
{/* Lightbox */}
{open !== null && (
<div
className="fixed inset-0 z-50 bg-ink/95 flex flex-col"
onClick={() => setOpen(null)}
role="dialog"
aria-modal="true"
onTouchStart={(e) => { touchStartX.current = e.touches[0].clientX; }}
onTouchEnd={(e) => {
if (touchStartX.current === null) return;
const dx = e.changedTouches[0].clientX - (touchStartX.current as number);
if (Math.abs(dx) > 50) go(dx < 0 ? 1 : -1);
touchStartX.current = null;
}}
>
{/* Top bar */}
<div className="shrink-0 flex items-center justify-between px-5 py-4" onClick={(e) => e.stopPropagation()}>
<span className="text-parchment/50 text-sm tabular-nums">{open + 1} / {images.length}</span>
<button
onClick={() => setOpen(null)}
className="text-parchment/70 hover:text-parchment transition-colors text-xl leading-none px-2 py-1"
aria-label="Galerie schließen"
>
</button>
</div>
{/* Image */}
<div
className="flex-1 flex items-center justify-center relative px-14 md:px-20 min-h-0"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={(e) => { e.stopPropagation(); go(-1); }}
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 text-parchment/60 hover:text-parchment text-5xl px-3 py-4 transition-colors select-none z-10"
aria-label="Vorheriges Bild"
></button>
<div className="relative w-full max-w-5xl aspect-[4/3]">
<Image
key={open}
src={images[open]}
alt={`${alt} — Ansicht ${open + 1}`}
fill
sizes="90vw"
className="object-contain"
/>
</div>
<button
onClick={(e) => { e.stopPropagation(); go(1); }}
className="absolute right-2 md:right-4 top-1/2 -translate-y-1/2 text-parchment/60 hover:text-parchment text-5xl px-3 py-4 transition-colors select-none z-10"
aria-label="Nächstes Bild"
></button>
</div>
{/* Thumbnail strip */}
<div
className="shrink-0 py-3 px-4"
onClick={(e) => e.stopPropagation()}
>
<div ref={thumbsRef} className="flex gap-2 overflow-x-auto" style={{ scrollbarWidth: "none" }}>
{images.map((src, idx) => (
<button
key={src}
onClick={() => setOpen(idx)}
className={`relative shrink-0 w-14 h-10 rounded overflow-hidden transition-all duration-200 ${
idx === open ? "ring-2 ring-parchment opacity-100" : "opacity-35 hover:opacity-65"
}`}
>
<Image src={src} alt="" fill sizes="56px" className="object-cover" />
</button>
))}
</div>
</div>
</div>
)}
</>
);
}