refactor(quality): Phase 2 — split templates, consolidate constants, in-house QR
Code quality pass driven by the plan. Eliminates the duplicated
constants, breaks the 216-line templates file into per-template files,
and removes the third-party QR dependency that was leaking memorial
URLs to api.qrserver.com.
Templates (src/lib/templates.tsx → src/lib/templates/):
- Split into Elegance.tsx, Cinematic.tsx, Serene.tsx and a shared.tsx
holding formatDates() and MemorialFooter.
- New index.tsx re-exports everything plus renderTemplate(), so the
existing '@@/lib/templates' import paths are unchanged.
- Adds eslint-disable-next-line @next/next/no-img-element markers on
the raw <img> tags so the linter (once it works again) won't flag
them; full migration to next/image is deferred to a later phase
pending next.config remotePatterns verification.
QR consolidation:
- dashboard/page.tsx now uses lib/qrcode.ts::generateMonumentQR
(server-rendered async) instead of api.qrserver.com. The external
service was logging every memorial URL to a third party.
- lib/qrcode.ts reads APP_DOMAIN from the shared config (was
process.env.NEXT_PUBLIC_APP_DOMAIN inline). Added
generateMonumentQRPng() helper for the download path.
- Dashboard 'Превземи QR код' link now points at the data: URL in-app
and downloads as '{subdomain}-qr.png'.
Config consolidation:
- SubdomainPicker.tsx now imports APP_DOMAIN, SUBDOMAIN_MIN_LENGTH
and SUBDOMAIN_MAX_LENGTH from lib/config; previously the slug was
normalized with a regex that didn't match the server's stricter
/^[a-z0-9][a-z0-9-]*[a-z0-9]$/ rule and the success message
hardcoded '.testbed.mk'.
- ImageUploader.tsx re-imports MAX_FILE_SIZE / ALLOWED_TYPES /
MAX_FILES from lib/upload (they were literally redefined inline,
causing drift risk). The remaining client-side filter uses a
string-cast to satisfy the readonly-tuple type.
This commit is contained in:
parent
3ff24cda0b
commit
c0effe6ec9
@ -6,6 +6,8 @@ import { UserButton } from "@clerk/nextjs";
|
|||||||
import CopyButton from "@/components/CopyButton";
|
import CopyButton from "@/components/CopyButton";
|
||||||
import DeleteMonumentButton from "@/components/DeleteMonumentButton";
|
import DeleteMonumentButton from "@/components/DeleteMonumentButton";
|
||||||
import DeleteImageButton from "@/components/DeleteImageButton";
|
import DeleteImageButton from "@/components/DeleteImageButton";
|
||||||
|
import { generateMonumentQR } from "@/lib/qrcode";
|
||||||
|
import { APP_DOMAIN } from "@/lib/config";
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const { userId } = await auth();
|
const { userId } = await auth();
|
||||||
@ -20,7 +22,8 @@ export default async function DashboardPage() {
|
|||||||
redirect("/onboarding");
|
redirect("/onboarding");
|
||||||
}
|
}
|
||||||
|
|
||||||
const monumentUrl = `https://${user.subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`;
|
const monumentUrl = `https://${user.subdomain}.${APP_DOMAIN}`;
|
||||||
|
const qrCode = await generateMonumentQR(user.subdomain);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-stone-50">
|
<div className="min-h-screen bg-stone-50">
|
||||||
@ -78,14 +81,15 @@ export default async function DashboardPage() {
|
|||||||
<h3 className="text-lg font-medium text-stone-900">QR код</h3>
|
<h3 className="text-lg font-medium text-stone-900">QR код</h3>
|
||||||
<p className="mt-1 text-sm text-stone-500">Прикажете го овој QR код на споменикот за да можат посетителите да ја прочитаат приказната.</p>
|
<p className="mt-1 text-sm text-stone-500">Прикажете го овој QR код на споменикот за да можат посетителите да ја прочитаат приказната.</p>
|
||||||
<div className="mt-4 rounded-lg border border-stone-200 bg-white p-4">
|
<div className="mt-4 rounded-lg border border-stone-200 bg-white p-4">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(monumentUrl)}`}
|
src={qrCode}
|
||||||
alt="QR код"
|
alt="QR код"
|
||||||
className="h-48 w-48"
|
className="h-48 w-48"
|
||||||
/>
|
/>
|
||||||
<a
|
<a
|
||||||
href={`https://api.qrserver.com/v1/create-qr-code/?size=400x400&data=${encodeURIComponent(monumentUrl)}`}
|
href={qrCode}
|
||||||
download
|
download={`${user.subdomain}-qr.png`}
|
||||||
className="mt-4 inline-block rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
|
className="mt-4 inline-block rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
|
||||||
>
|
>
|
||||||
Превземи QR код
|
Превземи QR код
|
||||||
|
|||||||
@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useUser } from "@clerk/nextjs";
|
import { useUser } from "@clerk/nextjs";
|
||||||
|
import { MAX_FILE_SIZE, ALLOWED_TYPES, MAX_FILES } from "@/lib/upload";
|
||||||
const MAX_FILES = 3;
|
|
||||||
const MAX_FILE_SIZE = 5 * 1024 * 1024;
|
|
||||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"];
|
|
||||||
|
|
||||||
interface ImageData {
|
interface ImageData {
|
||||||
key: string;
|
key: string;
|
||||||
@ -31,7 +28,7 @@ export default function ImageUploader({ images, onImagesChange }: ImageUploaderP
|
|||||||
if (remaining <= 0) return;
|
if (remaining <= 0) return;
|
||||||
|
|
||||||
const validFiles = Array.from(files)
|
const validFiles = Array.from(files)
|
||||||
.filter((f) => ALLOWED_TYPES.includes(f.type))
|
.filter((f) => (ALLOWED_TYPES as readonly string[]).includes(f.type))
|
||||||
.filter((f) => f.size <= MAX_FILE_SIZE)
|
.filter((f) => f.size <= MAX_FILE_SIZE)
|
||||||
.slice(0, remaining);
|
.slice(0, remaining);
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
APP_DOMAIN,
|
||||||
|
SUBDOMAIN_MIN_LENGTH,
|
||||||
|
SUBDOMAIN_MAX_LENGTH,
|
||||||
|
} from "@/lib/config";
|
||||||
|
|
||||||
interface SubdomainPickerProps {
|
interface SubdomainPickerProps {
|
||||||
value: string;
|
value: string;
|
||||||
@ -14,7 +19,7 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp
|
|||||||
const slug = value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
const slug = value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
||||||
|
|
||||||
const checkAvailability = useCallback(async (s: string) => {
|
const checkAvailability = useCallback(async (s: string) => {
|
||||||
if (s.length < 3) {
|
if (s.length < SUBDOMAIN_MIN_LENGTH) {
|
||||||
setAvailable(null);
|
setAvailable(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -51,10 +56,10 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp
|
|||||||
onChange={(e) => onChange(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-"))}
|
onChange={(e) => onChange(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-"))}
|
||||||
placeholder="нпр. maria-novakovska"
|
placeholder="нпр. maria-novakovska"
|
||||||
className="flex-1 rounded-l-lg border-0 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary"
|
className="flex-1 rounded-l-lg border-0 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
maxLength={63}
|
maxLength={SUBDOMAIN_MAX_LENGTH}
|
||||||
/>
|
/>
|
||||||
<span className="rounded-r-lg bg-stone-50 px-3 py-2 text-sm text-stone-500 border-l border-stone-200">
|
<span className="rounded-r-lg bg-stone-50 px-3 py-2 text-sm text-stone-500 border-l border-stone-200">
|
||||||
.{process.env.NEXT_PUBLIC_APP_DOMAIN || "testbed.mk"}
|
.{APP_DOMAIN}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -62,13 +67,13 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp
|
|||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{checking && <p className="text-stone-500">Проверка на достапност...</p>}
|
{checking && <p className="text-stone-500">Проверка на достапност...</p>}
|
||||||
{!checking && available === true && (
|
{!checking && available === true && (
|
||||||
<p className="text-green-600">✓ {slug}.testbed.mk е достапен!</p>
|
<p className="text-green-600">✓ {slug}.{APP_DOMAIN} е достапен!</p>
|
||||||
)}
|
)}
|
||||||
{!checking && available === false && (
|
{!checking && available === false && (
|
||||||
<p className="text-red-600">✗ Овој поддомен е веќе зафатен.</p>
|
<p className="text-red-600">✗ Овој поддомен е веќе зафатен.</p>
|
||||||
)}
|
)}
|
||||||
{!checking && available === null && slug.length > 0 && slug.length < 3 && (
|
{!checking && available === null && slug.length > 0 && slug.length < SUBDOMAIN_MIN_LENGTH && (
|
||||||
<p className="text-stone-400">Потребни се најмалку 3 карактери.</p>
|
<p className="text-stone-400">Потребни се најмалку {SUBDOMAIN_MIN_LENGTH} карактери.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import QRCode from "qrcode";
|
import QRCode from "qrcode";
|
||||||
|
import { APP_DOMAIN } from "./config";
|
||||||
|
|
||||||
export async function generateMonumentQR(subdomain: string): Promise<string> {
|
export async function generateMonumentQR(subdomain: string): Promise<string> {
|
||||||
const url = `https://${subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`;
|
const url = `https://${subdomain}.${APP_DOMAIN}`;
|
||||||
const qrBuffer = await QRCode.toBuffer(url, {
|
const qrBuffer = await QRCode.toBuffer(url, {
|
||||||
type: "png",
|
type: "png",
|
||||||
width: 400,
|
width: 400,
|
||||||
@ -13,4 +14,17 @@ export async function generateMonumentQR(subdomain: string): Promise<string> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return `data:image/png;base64,${qrBuffer.toString("base64")}`;
|
return `data:image/png;base64,${qrBuffer.toString("base64")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMonumentQRPng(subdomain: string): Promise<Buffer> {
|
||||||
|
const url = `https://${subdomain}.${APP_DOMAIN}`;
|
||||||
|
return QRCode.toBuffer(url, {
|
||||||
|
type: "png",
|
||||||
|
width: 400,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: "#1a1a2e",
|
||||||
|
light: "#ffffff",
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@ -1,216 +0,0 @@
|
|||||||
import type { MemorialData } from "@/types";
|
|
||||||
|
|
||||||
function formatDates(born: string | null, passed: string | null): string {
|
|
||||||
if (born && passed) return `${born} \u2014 ${passed}`;
|
|
||||||
if (passed) return passed;
|
|
||||||
if (born) return born;
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function MemorialFooter({ name }: { name: string | null }) {
|
|
||||||
return (
|
|
||||||
<footer className="border-t border-stone-200 py-10 text-center">
|
|
||||||
<div className="mx-auto mb-3 flex h-8 w-8 items-center justify-center rounded-full bg-amber-100">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5 text-amber-600">
|
|
||||||
<path d="M12 3C7 8 4 11 4 14.5C4 18 7 21 12 21C17 21 20 18 20 14.5C20 11 17 8 12 3Z" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm italic text-stone-400">Во спомен на</p>
|
|
||||||
{name && <p className="mt-1 text-xs text-stone-400">{name}</p>}
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderTemplate(templateId: number, data: MemorialData) {
|
|
||||||
switch (templateId) {
|
|
||||||
case 1:
|
|
||||||
return <TemplateElegance data={data} />;
|
|
||||||
case 2:
|
|
||||||
return <TemplateCinematic data={data} />;
|
|
||||||
case 3:
|
|
||||||
return <TemplateSerene data={data} />;
|
|
||||||
default:
|
|
||||||
return <TemplateElegance data={data} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplateElegance({ data }: { data: MemorialData }) {
|
|
||||||
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
|
|
||||||
const heroImage = sortedImages[0];
|
|
||||||
const gridImages = sortedImages.slice(1);
|
|
||||||
const dates = formatDates(data.bornDate, data.passedDate);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-stone-50 font-serif">
|
|
||||||
{heroImage && (
|
|
||||||
<div className="relative h-[55vh] w-full overflow-hidden">
|
|
||||||
<img src={heroImage.url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/70 via-stone-900/20 to-transparent" />
|
|
||||||
<div className="absolute bottom-10 left-0 right-0 text-center">
|
|
||||||
<h1 className="text-4xl font-bold tracking-wide text-white md:text-6xl drop-shadow-lg">
|
|
||||||
{data.title || "Во спомен на"}
|
|
||||||
</h1>
|
|
||||||
{dates && (
|
|
||||||
<p className="mt-3 text-lg tracking-widest text-stone-200 uppercase drop-shadow">
|
|
||||||
{dates}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!heroImage && (
|
|
||||||
<div className="mx-auto max-w-2xl px-6 pt-24 text-center">
|
|
||||||
<h1 className="text-4xl font-bold tracking-wide text-stone-900 md:text-6xl">
|
|
||||||
{data.title || "Во спомен на"}
|
|
||||||
</h1>
|
|
||||||
{dates && (
|
|
||||||
<p className="mt-4 text-lg tracking-widest text-stone-400 uppercase">{dates}</p>
|
|
||||||
)}
|
|
||||||
<div className="mx-auto mt-6 h-px w-16 bg-amber-400" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mx-auto max-w-3xl px-6 py-14">
|
|
||||||
{data.description && (
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-lg leading-relaxed text-stone-700 whitespace-pre-wrap md:text-xl">
|
|
||||||
{data.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{gridImages.length > 0 && (
|
|
||||||
<div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
{gridImages.map((img) => (
|
|
||||||
<div key={img.id} className="overflow-hidden rounded-lg shadow-md">
|
|
||||||
<img src={img.url} alt="" className="h-64 w-full object-cover transition-transform duration-500 hover:scale-105" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MemorialFooter name={data.title} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplateCinematic({ data }: { data: MemorialData }) {
|
|
||||||
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
|
|
||||||
const dates = formatDates(data.bornDate, data.passedDate);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
|
||||||
{sortedImages.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="relative h-screen w-full">
|
|
||||||
<img src={sortedImages[0].url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent" />
|
|
||||||
<div className="absolute bottom-16 left-8 right-8 md:left-16">
|
|
||||||
<h1 className="text-5xl font-black tracking-tight md:text-8xl drop-shadow-2xl">
|
|
||||||
{data.title || "Во спомен на"}
|
|
||||||
</h1>
|
|
||||||
{dates && (
|
|
||||||
<p className="mt-3 text-lg font-light tracking-widest text-zinc-300 uppercase md:text-xl">
|
|
||||||
{dates}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{data.description && (
|
|
||||||
<p className="mt-6 max-w-2xl text-base leading-relaxed text-zinc-300 md:text-lg line-clamp-4">
|
|
||||||
{data.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{sortedImages.length > 1 && (
|
|
||||||
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-3">
|
|
||||||
{sortedImages.slice(1).map((img) => (
|
|
||||||
<div key={img.id} className="relative aspect-square overflow-hidden">
|
|
||||||
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sortedImages.length === 0 && (
|
|
||||||
<div className="flex min-h-screen items-center justify-center px-8">
|
|
||||||
<div className="max-w-2xl text-center">
|
|
||||||
<h1 className="text-5xl font-black tracking-tight md:text-8xl">
|
|
||||||
{data.title || "Во спомен на"}
|
|
||||||
</h1>
|
|
||||||
{dates && (
|
|
||||||
<p className="mt-4 text-lg font-light tracking-widest text-zinc-400 uppercase">{dates}</p>
|
|
||||||
)}
|
|
||||||
{data.description && (
|
|
||||||
<p className="mt-8 text-base leading-relaxed text-zinc-400 whitespace-pre-wrap md:text-lg">
|
|
||||||
{data.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<footer className="py-12 text-center">
|
|
||||||
<div className="mx-auto mb-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/10">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5 text-zinc-500">
|
|
||||||
<path d="M12 3C7 8 4 11 4 14.5C4 18 7 21 12 21C17 21 20 18 20 14.5C20 11 17 8 12 3Z" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm italic text-zinc-500">Во спомен на</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TemplateSerene({ data }: { data: MemorialData }) {
|
|
||||||
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
|
|
||||||
const dates = formatDates(data.bornDate, data.passedDate);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white">
|
|
||||||
<div className="mx-auto max-w-xl px-6 py-20 md:py-28">
|
|
||||||
<div className="text-center">
|
|
||||||
{sortedImages.length > 0 && (
|
|
||||||
<div className="mx-auto mb-8 h-40 w-40 overflow-hidden rounded-full shadow-lg">
|
|
||||||
<img src={sortedImages[0].url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h1 className="text-3xl font-light tracking-tight text-zinc-900 md:text-5xl">
|
|
||||||
{data.title || "Во спомен на"}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
{dates && (
|
|
||||||
<p className="mt-3 text-sm tracking-[0.2em] text-zinc-400 uppercase">{dates}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mx-auto mt-6 h-px w-12 bg-blue-200" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.description && (
|
|
||||||
<div className="mt-10 border-l-2 border-blue-100 pl-6">
|
|
||||||
<p className="text-base leading-7 text-zinc-600 whitespace-pre-wrap md:text-lg">
|
|
||||||
{data.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{sortedImages.length > 1 && (
|
|
||||||
<div className="mt-12 flex gap-4 overflow-x-auto pb-4">
|
|
||||||
{sortedImages.slice(1).map((img) => (
|
|
||||||
<div key={img.id} className="flex-shrink-0">
|
|
||||||
<img src={img.url} alt="" className="h-56 w-auto rounded-lg shadow-sm object-cover" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MemorialFooter name={data.title} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
74
src/lib/templates/Cinematic.tsx
Normal file
74
src/lib/templates/Cinematic.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import type { MemorialData } from "@/types";
|
||||||
|
import { formatDates } from "./shared";
|
||||||
|
|
||||||
|
export function TemplateCinematic({ data }: { data: MemorialData }) {
|
||||||
|
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
|
||||||
|
const dates = formatDates(data.bornDate, data.passedDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-zinc-950 text-white">
|
||||||
|
{sortedImages.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="relative h-screen w-full">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={sortedImages[0].url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent" />
|
||||||
|
<div className="absolute bottom-16 left-8 right-8 md:left-16">
|
||||||
|
<h1 className="text-5xl font-black tracking-tight md:text-8xl drop-shadow-2xl">
|
||||||
|
{data.title || "Во спомен на"}
|
||||||
|
</h1>
|
||||||
|
{dates && (
|
||||||
|
<p className="mt-3 text-lg font-light tracking-widest text-zinc-300 uppercase md:text-xl">
|
||||||
|
{dates}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.description && (
|
||||||
|
<p className="mt-6 max-w-2xl text-base leading-relaxed text-zinc-300 md:text-lg line-clamp-4">
|
||||||
|
{data.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sortedImages.length > 1 && (
|
||||||
|
<div className="grid grid-cols-2 gap-0.5 md:grid-cols-3">
|
||||||
|
{sortedImages.slice(1).map((img) => (
|
||||||
|
<div key={img.id} className="relative aspect-square overflow-hidden">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={img.url} alt="" className="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sortedImages.length === 0 && (
|
||||||
|
<div className="flex min-h-screen items-center justify-center px-8">
|
||||||
|
<div className="max-w-2xl text-center">
|
||||||
|
<h1 className="text-5xl font-black tracking-tight md:text-8xl">
|
||||||
|
{data.title || "Во спомен на"}
|
||||||
|
</h1>
|
||||||
|
{dates && (
|
||||||
|
<p className="mt-4 text-lg font-light tracking-widest text-zinc-400 uppercase">{dates}</p>
|
||||||
|
)}
|
||||||
|
{data.description && (
|
||||||
|
<p className="mt-8 text-base leading-relaxed text-zinc-400 whitespace-pre-wrap md:text-lg">
|
||||||
|
{data.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="py-12 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-8 w-8 items-center justify-center rounded-full bg-white/10">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5 text-zinc-500">
|
||||||
|
<path d="M12 3C7 8 4 11 4 14.5C4 18 7 21 12 21C17 21 20 18 20 14.5C20 11 17 8 12 3Z" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm italic text-zinc-500">Во спомен на</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/lib/templates/Elegance.tsx
Normal file
66
src/lib/templates/Elegance.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import type { MemorialData } from "@/types";
|
||||||
|
import { formatDates, MemorialFooter } from "./shared";
|
||||||
|
|
||||||
|
export function TemplateElegance({ data }: { data: MemorialData }) {
|
||||||
|
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
|
||||||
|
const heroImage = sortedImages[0];
|
||||||
|
const gridImages = sortedImages.slice(1);
|
||||||
|
const dates = formatDates(data.bornDate, data.passedDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-stone-50 font-serif">
|
||||||
|
{heroImage && (
|
||||||
|
<div className="relative h-[55vh] w-full overflow-hidden">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={heroImage.url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-stone-900/70 via-stone-900/20 to-transparent" />
|
||||||
|
<div className="absolute bottom-10 left-0 right-0 text-center">
|
||||||
|
<h1 className="text-4xl font-bold tracking-wide text-white md:text-6xl drop-shadow-lg">
|
||||||
|
{data.title || "Во спомен на"}
|
||||||
|
</h1>
|
||||||
|
{dates && (
|
||||||
|
<p className="mt-3 text-lg tracking-widest text-stone-200 uppercase drop-shadow">
|
||||||
|
{dates}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!heroImage && (
|
||||||
|
<div className="mx-auto max-w-2xl px-6 pt-24 text-center">
|
||||||
|
<h1 className="text-4xl font-bold tracking-wide text-stone-900 md:text-6xl">
|
||||||
|
{data.title || "Во спомен на"}
|
||||||
|
</h1>
|
||||||
|
{dates && (
|
||||||
|
<p className="mt-4 text-lg tracking-widest text-stone-400 uppercase">{dates}</p>
|
||||||
|
)}
|
||||||
|
<div className="mx-auto mt-6 h-px w-16 bg-amber-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-3xl px-6 py-14">
|
||||||
|
{data.description && (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-lg leading-relaxed text-stone-700 whitespace-pre-wrap md:text-xl">
|
||||||
|
{data.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gridImages.length > 0 && (
|
||||||
|
<div className="mt-14 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{gridImages.map((img) => (
|
||||||
|
<div key={img.id} className="overflow-hidden rounded-lg shadow-md">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={img.url} alt="" className="h-64 w-full object-cover transition-transform duration-500 hover:scale-105" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MemorialFooter name={data.title} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/lib/templates/Serene.tsx
Normal file
53
src/lib/templates/Serene.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import type { MemorialData } from "@/types";
|
||||||
|
import { formatDates, MemorialFooter } from "./shared";
|
||||||
|
|
||||||
|
export function TemplateSerene({ data }: { data: MemorialData }) {
|
||||||
|
const sortedImages = [...data.images].sort((a, b) => a.order - b.order);
|
||||||
|
const dates = formatDates(data.bornDate, data.passedDate);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<div className="mx-auto max-w-xl px-6 py-20 md:py-28">
|
||||||
|
<div className="text-center">
|
||||||
|
{sortedImages.length > 0 && (
|
||||||
|
<div className="mx-auto mb-8 h-40 w-40 overflow-hidden rounded-full shadow-lg">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={sortedImages[0].url} alt={data.title || "Спомен"} className="h-full w-full object-cover object-top" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-light tracking-tight text-zinc-900 md:text-5xl">
|
||||||
|
{data.title || "Во спомен на"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{dates && (
|
||||||
|
<p className="mt-3 text-sm tracking-[0.2em] text-zinc-400 uppercase">{dates}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mx-auto mt-6 h-px w-12 bg-blue-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.description && (
|
||||||
|
<div className="mt-10 border-l-2 border-blue-100 pl-6">
|
||||||
|
<p className="text-base leading-7 text-zinc-600 whitespace-pre-wrap md:text-lg">
|
||||||
|
{data.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sortedImages.length > 1 && (
|
||||||
|
<div className="mt-12 flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{sortedImages.slice(1).map((img) => (
|
||||||
|
<div key={img.id} className="flex-shrink-0">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={img.url} alt="" className="h-56 w-auto rounded-lg shadow-sm object-cover" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MemorialFooter name={data.title} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/lib/templates/index.tsx
Normal file
20
src/lib/templates/index.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { MemorialData } from "@/types";
|
||||||
|
import { TemplateElegance } from "./Elegance";
|
||||||
|
import { TemplateCinematic } from "./Cinematic";
|
||||||
|
import { TemplateSerene } from "./Serene";
|
||||||
|
|
||||||
|
export { TemplateElegance, TemplateCinematic, TemplateSerene };
|
||||||
|
export { formatDates, MemorialFooter } from "./shared";
|
||||||
|
|
||||||
|
export function renderTemplate(templateId: number, data: MemorialData) {
|
||||||
|
switch (templateId) {
|
||||||
|
case 1:
|
||||||
|
return <TemplateElegance data={data} />;
|
||||||
|
case 2:
|
||||||
|
return <TemplateCinematic data={data} />;
|
||||||
|
case 3:
|
||||||
|
return <TemplateSerene data={data} />;
|
||||||
|
default:
|
||||||
|
return <TemplateElegance data={data} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/lib/templates/shared.tsx
Normal file
22
src/lib/templates/shared.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import type { MemorialData } from "@/types";
|
||||||
|
|
||||||
|
export function formatDates(born: string | null, passed: string | null): string {
|
||||||
|
if (born && passed) return `${born} \u2014 ${passed}`;
|
||||||
|
if (passed) return passed;
|
||||||
|
if (born) return born;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemorialFooter({ name }: { name: string | null }) {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-stone-200 py-10 text-center">
|
||||||
|
<div className="mx-auto mb-3 flex h-8 w-8 items-center justify-center rounded-full bg-amber-100">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="h-5 w-5 text-amber-600">
|
||||||
|
<path d="M12 3C7 8 4 11 4 14.5C4 18 7 21 12 21C17 21 20 18 20 14.5C20 11 17 8 12 3Z" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm italic text-stone-400">Во спомен на</p>
|
||||||
|
{name && <p className="mt-1 text-xs text-stone-400">{name}</p>}
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user