translation done
This commit is contained in:
parent
ff93e8c5be
commit
5aeb5a0db2
@ -15,11 +15,11 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
});
|
||||
|
||||
if (!user || !user.published) {
|
||||
return { title: "Memorial Not Found" };
|
||||
return { title: "Спомен страницата не е пронајдена" };
|
||||
}
|
||||
|
||||
const title = user.title || "Memorial";
|
||||
const description = user.description?.slice(0, 160) || `In Loving Memory of ${title}`;
|
||||
const title = user.title || "Спомен";
|
||||
const description = user.description?.slice(0, 160) || `Во спомен на ${title}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const slug = req.nextUrl.searchParams.get("slug");
|
||||
@ -8,6 +8,7 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ available: false });
|
||||
}
|
||||
|
||||
const { prisma } = await import("@/lib/prisma");
|
||||
const existing = await prisma.user.findUnique({ where: { subdomain: slug } });
|
||||
return NextResponse.json({ available: !existing });
|
||||
}
|
||||
@ -7,7 +7,7 @@ import { getPublicUrl } from "@/lib/upload";
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
@ -15,24 +15,24 @@ export async function POST(req: NextRequest) {
|
||||
const { title, description, bornDate, passedDate, subdomain, templateId, images } = body;
|
||||
|
||||
if (!title?.trim()) {
|
||||
return NextResponse.json({ error: "Title is required" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Името е задолжително" }, { status: 400 });
|
||||
}
|
||||
if (!subdomain || subdomain.length < 3) {
|
||||
return NextResponse.json({ error: "Subdomain must be at least 3 characters" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Поддоменот мора да има најмалку 3 карактери" }, { status: 400 });
|
||||
}
|
||||
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(subdomain)) {
|
||||
return NextResponse.json({ error: "Subdomain can only contain lowercase letters, numbers, and hyphens" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Поддоменот може да содржи само мали букви, цифри и цртички" }, { status: 400 });
|
||||
}
|
||||
if (templateId < 1 || templateId > 3) {
|
||||
return NextResponse.json({ error: "Invalid template" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Невалиден шаблон" }, { status: 400 });
|
||||
}
|
||||
if (!images || images.length === 0 || images.length > 3) {
|
||||
return NextResponse.json({ error: "1-3 images required" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Потребни се 1-3 фотографии" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { subdomain } });
|
||||
if (existing && existing.clerkId !== userId) {
|
||||
return NextResponse.json({ error: "Subdomain already taken" }, { status: 409 });
|
||||
return NextResponse.json({ error: "Поддоменот е веќе зафатен" }, { status: 409 });
|
||||
}
|
||||
|
||||
const existingUser = await prisma.user.findUnique({ where: { clerkId: userId } });
|
||||
@ -93,6 +93,6 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Publish error:", error);
|
||||
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
|
||||
return NextResponse.json({ error: "Внатрешна грешка на серверот" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -24,7 +24,7 @@ function getPublicUrl(key: string): string {
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
@ -32,15 +32,15 @@ export async function POST(req: NextRequest) {
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Нема подадено датотека" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return NextResponse.json({ error: "Invalid file type" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Невалиден тип на датотека" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: "File too large (max 5MB)" }, { status: 400 });
|
||||
return NextResponse.json({ error: "Датотеката е премногу голема (макс. 5MB)" }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = file.type.split("/")[1];
|
||||
@ -61,6 +61,6 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ key, url: publicUrl });
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
|
||||
return NextResponse.json({ error: "Не успеа качувањето" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@ export async function DELETE(
|
||||
) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { imageId } = await params;
|
||||
@ -21,13 +21,13 @@ export async function DELETE(
|
||||
});
|
||||
|
||||
if (!image || image.user.clerkId !== userId) {
|
||||
return NextResponse.json({ error: "Image not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: "Фотографијата не е пронајдена" }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteS3Object(image.key);
|
||||
} catch (e) {
|
||||
console.error("Failed to delete S3 object:", image.key, e);
|
||||
console.error("Не успеа бришењето на S3 објект:", image.key, e);
|
||||
}
|
||||
|
||||
await prisma.image.delete({ where: { id: imageId } });
|
||||
@ -47,6 +47,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Delete image error:", error);
|
||||
return NextResponse.json({ error: "Failed to delete image" }, { status: 500 });
|
||||
return NextResponse.json({ error: "Не успеа бришењето на фотографијата" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ import { deleteS3Object } from "@/lib/s3";
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
@ -16,14 +16,14 @@ export async function DELETE(req: NextRequest) {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Monument not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: "Споменот не е пронајден" }, { status: 404 });
|
||||
}
|
||||
|
||||
for (const image of user.images) {
|
||||
try {
|
||||
await deleteS3Object(image.key);
|
||||
} catch (e) {
|
||||
console.error("Failed to delete S3 object:", image.key, e);
|
||||
console.error("Не успеа бришењето на S3 објект:", image.key, e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,14 +32,14 @@ export async function DELETE(req: NextRequest) {
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Delete monument error:", error);
|
||||
return NextResponse.json({ error: "Failed to delete monument" }, { status: 500 });
|
||||
return NextResponse.json({ error: "Не успеа бришењето на споменот" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
@ -48,7 +48,7 @@ export async function GET() {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Monument not found" }, { status: 404 });
|
||||
return NextResponse.json({ error: "Споменот не е пронајден" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ user });
|
||||
@ -57,7 +57,7 @@ export async function GET() {
|
||||
export async function PUT(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "Неавторизирано" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
@ -80,6 +80,6 @@ export async function PUT(req: NextRequest) {
|
||||
return NextResponse.json({ user });
|
||||
} catch (error) {
|
||||
console.error("Update error:", error);
|
||||
return NextResponse.json({ error: "Failed to update monument" }, { status: 500 });
|
||||
return NextResponse.json({ error: "Не успеа ажурирањето на споменот" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@ -72,7 +72,7 @@ export default function EditPage() {
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex min-h-screen items-center justify-center">Loading...</div>;
|
||||
return <div className="flex min-h-screen items-center justify-center">Вчитување...</div>;
|
||||
}
|
||||
|
||||
const imagePreviews = images.map((img) => ({ url: img.url, order: img.order }));
|
||||
@ -81,23 +81,22 @@ export default function EditPage() {
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<header className="border-b border-stone-200 bg-white">
|
||||
<div className="mx-auto max-w-6xl px-6 py-4 flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-stone-900">Edit Memorial</h1>
|
||||
<h1 className="text-xl font-semibold text-stone-900">Уреди спомен</h1>
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50 lg:hidden"
|
||||
>
|
||||
{showPreview ? "Edit" : "Preview"}
|
||||
{showPreview ? "Уреди" : "Преглед"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto max-w-6xl px-6 py-8">
|
||||
<div className="flex gap-8">
|
||||
{/* Form - hidden on mobile when preview is shown */}
|
||||
<div className={`flex-1 min-w-0 ${showPreview ? "hidden lg:block" : ""}`}>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-stone-700">Name</label>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-stone-700">Име</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
@ -110,25 +109,25 @@ export default function EditPage() {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="bornDate" className="block text-sm font-medium text-stone-700">Born</label>
|
||||
<label htmlFor="bornDate" className="block text-sm font-medium text-stone-700">Роден/а</label>
|
||||
<input
|
||||
id="bornDate"
|
||||
type="text"
|
||||
value={data.bornDate}
|
||||
onChange={(e) => setData({ ...data, bornDate: e.target.value })}
|
||||
placeholder="e.g. 1960"
|
||||
placeholder="нпр. 1960"
|
||||
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="passedDate" className="block text-sm font-medium text-stone-700">Passed Away</label>
|
||||
<label htmlFor="passedDate" className="block text-sm font-medium text-stone-700">Починат/а</label>
|
||||
<input
|
||||
id="passedDate"
|
||||
type="text"
|
||||
value={data.passedDate}
|
||||
onChange={(e) => setData({ ...data, passedDate: e.target.value })}
|
||||
placeholder="e.g. 2024"
|
||||
placeholder="нпр. 2024"
|
||||
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={50}
|
||||
/>
|
||||
@ -136,7 +135,7 @@ export default function EditPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-stone-700">Epitaph / Life Story</label>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-stone-700">Епитафија / Животна приказна</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={data.description}
|
||||
@ -148,12 +147,12 @@ export default function EditPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">Template</label>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">Шаблон</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ id: 1, name: "Elegance" },
|
||||
{ id: 2, name: "Cinematic" },
|
||||
{ id: 3, name: "Serene" },
|
||||
{ id: 1, name: "Елеганција" },
|
||||
{ id: 2, name: "Кинематски" },
|
||||
{ id: 3, name: "Спокој" },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
@ -171,7 +170,7 @@ export default function EditPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">Photos</label>
|
||||
<label className="block text-sm font-medium text-stone-700 mb-2">Фотографии</label>
|
||||
{images.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{images.map((img) => (
|
||||
@ -187,7 +186,7 @@ export default function EditPage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-stone-400">No photos yet.</p>
|
||||
<p className="text-sm text-stone-400">Нема фотографии.</p>
|
||||
)}
|
||||
{images.length < 3 && (
|
||||
<div className="mt-4">
|
||||
@ -201,19 +200,18 @@ export default function EditPage() {
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button onClick={handleSave} disabled={saving} className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:opacity-50">
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
{saving ? "Зачувување..." : "Зачувај промени"}
|
||||
</button>
|
||||
<button onClick={() => router.push("/dashboard")} className="rounded-lg border border-stone-200 px-6 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50">
|
||||
Cancel
|
||||
Откажи
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live preview - always visible on desktop, toggle on mobile */}
|
||||
<div className={`w-[420px] flex-shrink-0 ${showPreview ? "" : "hidden lg:block"}`}>
|
||||
<div className="sticky top-8">
|
||||
<p className="mb-2 text-xs font-medium text-stone-500 uppercase tracking-wider">Live Preview</p>
|
||||
<p className="mb-2 text-xs font-medium text-stone-500 uppercase tracking-wider">Преглед во живо</p>
|
||||
<div className="overflow-hidden rounded-lg border border-stone-300 shadow-md">
|
||||
<div className="h-[600px] overflow-y-auto">
|
||||
<TemplatePicker
|
||||
|
||||
@ -26,21 +26,21 @@ export default async function DashboardPage() {
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<header className="border-b border-stone-200 bg-white">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<h1 className="text-xl font-semibold text-stone-900">SpomeniQR</h1>
|
||||
<h1 className="text-xl font-semibold text-stone-900">СпоменQR</h1>
|
||||
<UserButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
||||
<div className="rounded-lg border border-stone-200 bg-white p-6">
|
||||
<h2 className="text-2xl font-semibold text-stone-900">{user.title || "Untitled Memorial"}</h2>
|
||||
<h2 className="text-2xl font-semibold text-stone-900">{user.title || "Неименуван спомен"}</h2>
|
||||
{(user.bornDate || user.passedDate) && (
|
||||
<p className="mt-1 text-sm tracking-wider text-stone-400">
|
||||
{user.bornDate}{user.bornDate && user.passedDate ? " — " : ""}{user.passedDate}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-sm text-stone-500">
|
||||
{user.published ? "Published" : "Draft"} · Subdomain: <span className="font-mono">{user.subdomain}</span>
|
||||
{user.published ? "Објавено" : "Нацрт"} · Поддомен: <span className="font-mono">{user.subdomain}</span>
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
@ -49,20 +49,20 @@ export default async function DashboardPage() {
|
||||
target="_blank"
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
|
||||
>
|
||||
View Monument
|
||||
Погледај спомен
|
||||
</Link>
|
||||
<Link
|
||||
href="/dashboard/edit"
|
||||
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50"
|
||||
>
|
||||
Edit
|
||||
Уреди
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.images.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-medium text-stone-900">Images</h3>
|
||||
<h3 className="text-lg font-medium text-stone-900">Фотографии</h3>
|
||||
<div className="mt-3 grid grid-cols-3 gap-4">
|
||||
{user.images.map((img) => (
|
||||
<div key={img.id} className="group relative overflow-hidden rounded-lg border border-stone-200">
|
||||
@ -75,12 +75,12 @@ export default async function DashboardPage() {
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-medium text-stone-900">Monument QR Code</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">Download and display this QR code at your monument location.</p>
|
||||
<h3 className="text-lg font-medium text-stone-900">QR код</h3>
|
||||
<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">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(monumentUrl)}`}
|
||||
alt="QR Code"
|
||||
alt="QR код"
|
||||
className="h-48 w-48"
|
||||
/>
|
||||
<a
|
||||
@ -88,13 +88,13 @@ export default async function DashboardPage() {
|
||||
download
|
||||
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"
|
||||
>
|
||||
Download QR
|
||||
Превземи QR код
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-medium text-stone-900">Share Link</h3>
|
||||
<h3 className="text-lg font-medium text-stone-900">Сподели линк</h3>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@ -107,8 +107,8 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border-t border-stone-200 pt-6">
|
||||
<h3 className="text-sm font-medium text-red-600">Danger Zone</h3>
|
||||
<p className="mt-1 text-xs text-stone-500">Permanently delete your monument and all associated images.</p>
|
||||
<h3 className="text-sm font-medium text-red-600">Опасна зона</h3>
|
||||
<p className="mt-1 text-xs text-stone-500">Трачно избришете го вашиот спомен и сите поврзани фотографии.</p>
|
||||
<div className="mt-3">
|
||||
<DeleteMonumentButton />
|
||||
</div>
|
||||
|
||||
@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SpomeniQR — In Loving Memory",
|
||||
description: "Create beautiful memorial pages with QR codes. Honor and remember those who meant the world to you.",
|
||||
title: "СпоменQR — Во спомен на",
|
||||
description: "Креирајте убави спомен страници со QR кодови. Оддадете почит и зачувајте ги спомените на најблиските.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@ -25,7 +25,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<ClerkProvider>
|
||||
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
||||
<html lang="mk" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
</ClerkProvider>
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "СпоменQR — Во спомен на",
|
||||
description: "Креирајте убави спомен страници со QR кодови. Оддадете почит и зачувајте ги спомените на најблиските.",
|
||||
};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-6">
|
||||
<h1 className="text-4xl font-bold text-stone-900">Memorial Not Found</h1>
|
||||
<p className="mt-4 text-stone-600">This memorial page does not exist or has not been published yet.</p>
|
||||
<Link
|
||||
<h1 className="text-4xl font-bold text-stone-900">Спомен страницата не е пронајдена</h1>
|
||||
<p className="mt-4 text-stone-600">Оваа спомен страница не постои или сè уште не е објавена.</p>
|
||||
<a
|
||||
href="/"
|
||||
className="mt-8 rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
Почетна
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,7 +6,7 @@ import ImageUploader from "@/components/ImageUploader";
|
||||
import SubdomainPicker from "@/components/SubdomainPicker";
|
||||
import TemplatePicker from "@/components/TemplatePicker";
|
||||
|
||||
const STEPS = ["Details", "Dates", "Photos", "Subdomain", "Template"] as const;
|
||||
const STEPS = ["Податоци", "Датуми", "Фотографии", "Поддомен", "Шаблон"] as const;
|
||||
|
||||
export default function OnboardingWizard() {
|
||||
const router = useRouter();
|
||||
@ -52,11 +52,11 @@ export default function OnboardingWizard() {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to publish");
|
||||
throw new Error(data.error || "Не успеа објавувањето");
|
||||
}
|
||||
router.push("/dashboard");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong");
|
||||
setError(err instanceof Error ? err.message : "Нешто тргна наопаку");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -70,7 +70,7 @@ export default function OnboardingWizard() {
|
||||
<div className="min-h-screen bg-stone-50">
|
||||
<header className="border-b border-stone-200 bg-white">
|
||||
<div className="mx-auto max-w-2xl px-6 py-4">
|
||||
<h1 className="text-xl font-semibold text-stone-900">Create a Memorial Page</h1>
|
||||
<h1 className="text-xl font-semibold text-stone-900">Креирај спомен страница</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -103,27 +103,27 @@ export default function OnboardingWizard() {
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-stone-700">
|
||||
Name
|
||||
Име
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Maria Novakova"
|
||||
placeholder="нпр. Марија Новаковска"
|
||||
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-stone-700">
|
||||
Epitaph / Life Story
|
||||
Епитафија / Животна приказна
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="A few words, a poem, or a biography to honor their memory..."
|
||||
placeholder="Неколку зборови, песма или биографија во нивна чест..."
|
||||
rows={6}
|
||||
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={2000}
|
||||
@ -135,32 +135,32 @@ export default function OnboardingWizard() {
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-stone-500">
|
||||
These are optional. You can enter exact dates, approximate years, or leave them blank.
|
||||
Овие се опционални. Можете да внесете точни датуми, приближни години, или да ги оставите празни.
|
||||
</p>
|
||||
<div>
|
||||
<label htmlFor="bornDate" className="block text-sm font-medium text-stone-700">
|
||||
Born
|
||||
Роден/а
|
||||
</label>
|
||||
<input
|
||||
id="bornDate"
|
||||
type="text"
|
||||
value={bornDate}
|
||||
onChange={(e) => setBornDate(e.target.value)}
|
||||
placeholder="e.g. 1960 or March 15, 1960"
|
||||
placeholder="нпр. 1960 или 15 март 1960"
|
||||
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="passedDate" className="block text-sm font-medium text-stone-700">
|
||||
Passed Away
|
||||
Починат/а
|
||||
</label>
|
||||
<input
|
||||
id="passedDate"
|
||||
type="text"
|
||||
value={passedDate}
|
||||
onChange={(e) => setPassedDate(e.target.value)}
|
||||
placeholder="e.g. 2024 or November 20, 2024"
|
||||
placeholder="нпр. 2024 или 20 ноември 2024"
|
||||
className="mt-1 block w-full rounded-lg border border-stone-200 px-3 py-2.5 text-stone-900 placeholder:text-stone-400 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
maxLength={50}
|
||||
/>
|
||||
@ -197,7 +197,7 @@ export default function OnboardingWizard() {
|
||||
onClick={() => setStep(step - 1)}
|
||||
className="rounded-lg border border-stone-200 px-6 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50"
|
||||
>
|
||||
Back
|
||||
Назад
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
@ -208,7 +208,7 @@ export default function OnboardingWizard() {
|
||||
disabled={!canProceed()}
|
||||
className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Continue
|
||||
Продолжи
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@ -216,7 +216,7 @@ export default function OnboardingWizard() {
|
||||
disabled={!canProceed() || loading}
|
||||
className="rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Publishing..." : "Publish Memorial"}
|
||||
{loading ? "Објавување..." : "Објави спомен"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,7 @@ export default async function HomePage() {
|
||||
<header className="border-b border-stone-200 bg-white">
|
||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||
<Link href="/" className="text-xl font-bold text-primary">
|
||||
SpomeniQR
|
||||
СпоменQR
|
||||
</Link>
|
||||
<NavAuth />
|
||||
</div>
|
||||
@ -17,10 +17,10 @@ export default async function HomePage() {
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<div className="mb-6 text-5xl">💎</div>
|
||||
<h2 className="text-4xl font-bold tracking-tight text-stone-900 sm:text-5xl">
|
||||
In Loving Memory
|
||||
Во спомен на
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-stone-600">
|
||||
Create beautiful memorial pages with QR codes. Honor and remember those who meant the world to you.
|
||||
Креирајте убави спомен страници со QR кодови. Оддадете почит и зачувајте ги спомените на најблиските.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<LandingAuth />
|
||||
@ -29,25 +29,25 @@ export default async function HomePage() {
|
||||
<div className="mt-16 grid grid-cols-1 gap-8 text-left sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-stone-200 bg-white p-6">
|
||||
<div className="mb-3 text-2xl">✍</div>
|
||||
<h3 className="font-semibold text-stone-900">Tell Their Story</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">Write an epitaph, biography, or poem to honor their memory.</p>
|
||||
<h3 className="font-semibold text-stone-900">Раскажете нивната приказна</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">Напишете епитафија, биографија или песма во нивна чест.</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-stone-200 bg-white p-6">
|
||||
<div className="mb-3 text-2xl">📷</div>
|
||||
<h3 className="font-semibold text-stone-900">Share Photos</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">Add up to 3 photos that capture their spirit and legacy.</p>
|
||||
<h3 className="font-semibold text-stone-900">Споделете фотографии</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">Додадете до 3 фотографии што го прикажуваат нивниот дух и наслед.</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-stone-200 bg-white p-6">
|
||||
<div className="mb-3 text-2xl">📱</div>
|
||||
<h3 className="font-semibold text-stone-900">QR Code at the Grave</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">Visitors scan the QR code to read their story, right at the memorial site.</p>
|
||||
<h3 className="font-semibold text-stone-900">QR код на споменикот</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">Посетителите скенираат QR код за да ја прочитаат нивната приказна, директно на споменикот.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-stone-200 py-6 text-center text-sm text-stone-400">
|
||||
SpomeniQR — In Loving Memory
|
||||
СпоменQR — Во спомен на
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,12 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export default function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(text)}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}}
|
||||
className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50"
|
||||
>
|
||||
Copy
|
||||
{copied ? "Копирано!" : "Копирај"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -19,10 +19,10 @@ export default function DeleteImageButton({ imageId }: DeleteImageButtonProps) {
|
||||
router.refresh();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || "Failed to delete image");
|
||||
alert(data.error || "Не успеа бришењето");
|
||||
}
|
||||
} catch {
|
||||
alert("Something went wrong");
|
||||
alert("Нешто тргна наопаку");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
@ -33,7 +33,7 @@ export default function DeleteImageButton({ imageId }: DeleteImageButtonProps) {
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 disabled:opacity-50"
|
||||
title="Delete image"
|
||||
title="Избриши фотографија"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@ -17,10 +17,10 @@ export default function DeleteMonumentButton() {
|
||||
router.refresh();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || "Failed to delete monument");
|
||||
alert(data.error || "Не успеа бришењето на споменот");
|
||||
}
|
||||
} catch {
|
||||
alert("Something went wrong");
|
||||
alert("Нешто тргна наопаку");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setShowConfirm(false);
|
||||
@ -30,20 +30,20 @@ export default function DeleteMonumentButton() {
|
||||
if (showConfirm) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-sm font-medium text-red-800">Are you sure? This will permanently delete your monument and all its images.</p>
|
||||
<p className="text-sm font-medium text-red-800">Дали сте сигурни? Ова ќе го избрише вашиот спомен и сите фотографии трајно.</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Yes, delete forever"}
|
||||
{deleting ? "Бришење..." : "Да, избриши трајно"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className="rounded-lg border border-stone-200 bg-white px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50"
|
||||
>
|
||||
Cancel
|
||||
Откажи
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -55,7 +55,7 @@ export default function DeleteMonumentButton() {
|
||||
onClick={() => setShowConfirm(true)}
|
||||
className="rounded-lg border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-50"
|
||||
>
|
||||
Delete Monument
|
||||
Избриши спомен
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -14,8 +14,8 @@ interface ImageData {
|
||||
}
|
||||
|
||||
interface ImageUploaderProps {
|
||||
images: { key: string; order: number }[];
|
||||
onImagesChange: (images: { key: string; order: number }[]) => void;
|
||||
images: { key: string; order: number; url?: string }[];
|
||||
onImagesChange: (images: { key: string; order: number; url?: string }[]) => void;
|
||||
}
|
||||
|
||||
export default function ImageUploader({ images, onImagesChange }: ImageUploaderProps) {
|
||||
@ -24,8 +24,10 @@ export default function ImageUploader({ images, onImagesChange }: ImageUploaderP
|
||||
const [previews, setPreviews] = useState<ImageData[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const currentCount = images.length;
|
||||
|
||||
const handleFiles = async (files: FileList) => {
|
||||
const remaining = MAX_FILES - images.length;
|
||||
const remaining = MAX_FILES - currentCount;
|
||||
if (remaining <= 0) return;
|
||||
|
||||
const validFiles = Array.from(files)
|
||||
@ -34,7 +36,7 @@ export default function ImageUploader({ images, onImagesChange }: ImageUploaderP
|
||||
.slice(0, remaining);
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
setError("Invalid file type or size. Accepted: JPEG, PNG, WebP, GIF up to 5MB.");
|
||||
setError("Невалиден тип на датотека или големина. Дозволени: JPEG, PNG, WebP, GIF до 5MB.");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -56,53 +58,48 @@ export default function ImageUploader({ images, onImagesChange }: ImageUploaderP
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Upload failed");
|
||||
throw new Error(data.error || "Не успеа качувањето");
|
||||
}
|
||||
|
||||
const { key, url } = await res.json();
|
||||
const order = newImages.length + 1;
|
||||
newImages.push({ key, order });
|
||||
newImages.push({ key, order, url });
|
||||
newPreviews.push({ key, url, order });
|
||||
}
|
||||
|
||||
onImagesChange(newImages);
|
||||
setPreviews(newPreviews);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Upload failed");
|
||||
setError(err instanceof Error ? err.message : "Не успеа качувањето");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (key: string) => {
|
||||
const idx = images.findIndex((img) => img.key === key);
|
||||
if (idx === -1) return;
|
||||
const updated = images
|
||||
.filter((img) => img.key !== key)
|
||||
.map((img, i) => ({ ...img, order: i + 1 }));
|
||||
onImagesChange(updated);
|
||||
setPreviews(previews.filter((p) => p.key !== key));
|
||||
};
|
||||
const remaining = MAX_FILES - currentCount;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-stone-600">
|
||||
Upload up to {MAX_FILES} photos (max {MAX_FILE_SIZE / 1024 / 1024}MB each, JPEG/PNG/WebP/GIF)
|
||||
Качете до {MAX_FILES} фотографии (макс. {MAX_FILE_SIZE / 1024 / 1024}MB секоја, JPEG/PNG/WebP/GIF)
|
||||
</p>
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-stone-300 bg-stone-50 px-6 py-8 transition-colors hover:border-primary hover:bg-stone-100">
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-stone-700">
|
||||
{uploading ? "Uploading..." : "Click to upload or drag and drop"}
|
||||
{uploading ? "Качување..." : "Кликнете за качување или влечете и пуштете"}
|
||||
</p>
|
||||
{remaining > 0 && !uploading && (
|
||||
<p className="mt-1 text-xs text-stone-400">Преостануваат {remaining} места</p>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept={ALLOWED_TYPES.join(",")}
|
||||
multiple
|
||||
className="hidden"
|
||||
disabled={uploading || images.length >= MAX_FILES}
|
||||
disabled={uploading || currentCount >= MAX_FILES}
|
||||
onChange={(e) => e.target.files && handleFiles(e.target.files)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
@ -123,7 +120,13 @@ export default function ImageUploader({ images, onImagesChange }: ImageUploaderP
|
||||
<div key={preview.key} className="group relative overflow-hidden rounded-lg border border-stone-200">
|
||||
<img src={preview.url} alt="" className="h-32 w-full object-cover" />
|
||||
<button
|
||||
onClick={() => removeImage(preview.key)}
|
||||
onClick={() => {
|
||||
const idx = images.findIndex((img) => img.key === preview.key);
|
||||
if (idx === -1) return;
|
||||
const updated = images.filter((img) => img.key !== preview.key).map((img, i) => ({ ...img, order: i + 1 }));
|
||||
onImagesChange(updated);
|
||||
setPreviews(previews.filter((p) => p.key !== preview.key));
|
||||
}}
|
||||
className="absolute right-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
×
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SignInButton, SignUpButton, UserButton } from "@clerk/nextjs";
|
||||
import { useAuth } from "@clerk/nextjs";
|
||||
import Link from "next/link";
|
||||
@ -15,12 +16,12 @@ export default function NavAuth() {
|
||||
<div className="flex items-center gap-3">
|
||||
<SignInButton mode="redirect" fallbackRedirectUrl="/dashboard">
|
||||
<button className="rounded-lg border border-stone-200 px-4 py-2 text-sm font-medium text-stone-700 transition-colors hover:bg-stone-50">
|
||||
Sign In
|
||||
Најави се
|
||||
</button>
|
||||
</SignInButton>
|
||||
<SignUpButton mode="redirect" fallbackRedirectUrl="/onboarding">
|
||||
<button className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-primary-light">
|
||||
Sign Up
|
||||
Регистрирај се
|
||||
</button>
|
||||
</SignUpButton>
|
||||
</div>
|
||||
@ -36,7 +37,7 @@ export function LandingAuth() {
|
||||
href="/dashboard"
|
||||
className="rounded-lg bg-primary px-8 py-3 text-base font-medium text-white transition-colors hover:bg-primary-light"
|
||||
>
|
||||
Go to Dashboard
|
||||
Оди на контролна табла
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@ -45,12 +46,12 @@ export function LandingAuth() {
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
|
||||
<SignInButton mode="redirect" fallbackRedirectUrl="/dashboard">
|
||||
<button className="rounded-lg border border-stone-200 px-8 py-3 text-base font-medium text-stone-700 transition-colors hover:bg-stone-50">
|
||||
Sign In
|
||||
Најави се
|
||||
</button>
|
||||
</SignInButton>
|
||||
<SignUpButton mode="redirect" fallbackRedirectUrl="/onboarding">
|
||||
<button className="rounded-lg bg-primary px-8 py-3 text-base font-medium text-white transition-colors hover:bg-primary-light">
|
||||
Get Started
|
||||
Започни
|
||||
</button>
|
||||
</SignUpButton>
|
||||
</div>
|
||||
|
||||
@ -41,7 +41,7 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="subdomain" className="block text-sm font-medium text-stone-700">
|
||||
Choose your subdomain
|
||||
Изберете поддомен
|
||||
</label>
|
||||
<div className="mt-1 flex items-center rounded-lg border border-stone-200 bg-white">
|
||||
<input
|
||||
@ -49,7 +49,7 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "").replace(/-+/g, "-"))}
|
||||
placeholder="e.g. eiffel-tower"
|
||||
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"
|
||||
maxLength={63}
|
||||
/>
|
||||
@ -60,15 +60,15 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
{checking && <p className="text-stone-500">Checking availability...</p>}
|
||||
{checking && <p className="text-stone-500">Проверка на достапност...</p>}
|
||||
{!checking && available === true && (
|
||||
<p className="text-green-600">✓ {slug}.testbed.mk is available!</p>
|
||||
<p className="text-green-600">✓ {slug}.testbed.mk е достапен!</p>
|
||||
)}
|
||||
{!checking && available === false && (
|
||||
<p className="text-red-600">✗ This subdomain is already taken.</p>
|
||||
<p className="text-red-600">✗ Овој поддомен е веќе зафатен.</p>
|
||||
)}
|
||||
{!checking && available === null && slug.length > 0 && slug.length < 3 && (
|
||||
<p className="text-stone-400">At least 3 characters required.</p>
|
||||
<p className="text-stone-400">Потребни се најмалку 3 карактери.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -13,15 +13,15 @@ interface TemplatePickerProps {
|
||||
}
|
||||
|
||||
const TEMPLATES = [
|
||||
{ id: 1, name: "Elegance", tagline: "Serif warmth, dignified tribute" },
|
||||
{ id: 2, name: "Cinematic", tagline: "Bold, immersive, full-screen" },
|
||||
{ id: 3, name: "Serene", tagline: "Peaceful, centered, spiritual" },
|
||||
{ id: 1, name: "Елеганција", tagline: "Серифна топлина, достоенственEMA" },
|
||||
{ id: 2, name: "Кинематски", tagline: "Смело, потопно, целосно екранско" },
|
||||
{ id: 3, name: "Спокој", tagline: "Мирно, центрирано, духовно" },
|
||||
];
|
||||
|
||||
export default function TemplatePicker({ value, onChange, title, description, bornDate, passedDate, images }: TemplatePickerProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-stone-600">Choose how the memorial page will look. Scroll the preview to see the full page.</p>
|
||||
<p className="text-sm text-stone-600">Изберете како ќе изгледа спомен страницата. Скролајте за цел преглед.</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{TEMPLATES.map((t) => (
|
||||
@ -42,7 +42,7 @@ export default function TemplatePicker({ value, onChange, title, description, bo
|
||||
|
||||
{value && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-stone-500 uppercase tracking-wider">Live Preview</p>
|
||||
<p className="text-xs font-medium text-stone-500 uppercase tracking-wider">Преглед во живо</p>
|
||||
<div className="overflow-hidden rounded-lg border border-stone-300 shadow-md">
|
||||
<div className="h-[500px] overflow-y-auto">
|
||||
<MemorialPreview
|
||||
|
||||
@ -15,7 +15,7 @@ function MemorialFooter({ name }: { name: string | null }) {
|
||||
<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">In Loving Memory</p>
|
||||
<p className="text-sm italic text-stone-400">Во спомен на</p>
|
||||
{name && <p className="mt-1 text-xs text-stone-400">{name}</p>}
|
||||
</footer>
|
||||
);
|
||||
@ -44,11 +44,11 @@ export function TemplateElegance({ data }: { data: MemorialData }) {
|
||||
<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 || "Memorial"} className="h-full w-full object-cover" />
|
||||
<img src={heroImage.url} alt={data.title || "Спомен"} className="h-full w-full object-cover" />
|
||||
<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 || "In Loving Memory"}
|
||||
{data.title || "Во спомен на"}
|
||||
</h1>
|
||||
{dates && (
|
||||
<p className="mt-3 text-lg tracking-widest text-stone-200 uppercase drop-shadow">
|
||||
@ -62,7 +62,7 @@ export function TemplateElegance({ data }: { data: MemorialData }) {
|
||||
{!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 || "In Loving Memory"}
|
||||
{data.title || "Во спомен на"}
|
||||
</h1>
|
||||
{dates && (
|
||||
<p className="mt-4 text-lg tracking-widest text-stone-400 uppercase">{dates}</p>
|
||||
@ -105,11 +105,11 @@ export function TemplateCinematic({ data }: { data: MemorialData }) {
|
||||
{sortedImages.length > 0 && (
|
||||
<>
|
||||
<div className="relative h-screen w-full">
|
||||
<img src={sortedImages[0].url} alt={data.title || "Memorial"} className="h-full w-full object-cover" />
|
||||
<img src={sortedImages[0].url} alt={data.title || "Спомен"} className="h-full w-full object-cover" />
|
||||
<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 || "In Loving Memory"}
|
||||
{data.title || "Во спомен на"}
|
||||
</h1>
|
||||
{dates && (
|
||||
<p className="mt-3 text-lg font-light tracking-widest text-zinc-300 uppercase md:text-xl">
|
||||
@ -140,7 +140,7 @@ export function TemplateCinematic({ data }: { data: MemorialData }) {
|
||||
<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 || "In Loving Memory"}
|
||||
{data.title || "Во спомен на"}
|
||||
</h1>
|
||||
{dates && (
|
||||
<p className="mt-4 text-lg font-light tracking-widest text-zinc-400 uppercase">{dates}</p>
|
||||
@ -160,7 +160,7 @@ export function TemplateCinematic({ data }: { data: MemorialData }) {
|
||||
<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">In Loving Memory</p>
|
||||
<p className="text-sm italic text-zinc-500">Во спомен на</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
@ -176,12 +176,12 @@ export function TemplateSerene({ data }: { data: MemorialData }) {
|
||||
<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 || "Memorial"} className="h-full w-full object-cover" />
|
||||
<img src={sortedImages[0].url} alt={data.title || "Спомен"} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-3xl font-light tracking-tight text-zinc-900 md:text-5xl">
|
||||
{data.title || "In Loving Memory"}
|
||||
{data.title || "Во спомен на"}
|
||||
</h1>
|
||||
|
||||
{dates && (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user