translation done

This commit is contained in:
echo 2026-06-20 19:44:36 +02:00
parent ff93e8c5be
commit 5aeb5a0db2
20 changed files with 170 additions and 154 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"} &middot; Subdomain: <span className="font-mono">{user.subdomain}</span>
{user.published ? "Објавено" : "Нацрт"} &middot; Поддомен: <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>

View File

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

View File

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

View File

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

View File

@ -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">&#128142;</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">&#9997;</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">&#128247;</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">&#128241;</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>
);

View File

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

View File

@ -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="Избриши фотографија"
>
&times;
</button>

View File

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

View File

@ -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"
>
&times;

View File

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

View File

@ -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">&#10003; {slug}.testbed.mk is available!</p>
<p className="text-green-600">&#10003; {slug}.testbed.mk е достапен!</p>
)}
{!checking && available === false && (
<p className="text-red-600">&#10007; This subdomain is already taken.</p>
<p className="text-red-600">&#10007; Овој поддомен е веќе зафатен.</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>

View File

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

View File

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