From c0effe6ec9cf99d770e261869588f1b01c0989fd Mon Sep 17 00:00:00 2001 From: dimitar Date: Sun, 2 Aug 2026 12:15:19 +0200 Subject: [PATCH] =?UTF-8?q?refactor(quality):=20Phase=202=20=E2=80=94=20sp?= =?UTF-8?q?lit=20templates,=20consolidate=20constants,=20in-house=20QR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- src/app/dashboard/page.tsx | 12 +- src/components/ImageUploader.tsx | 7 +- src/components/SubdomainPicker.tsx | 17 ++- src/lib/qrcode.ts | 16 ++- src/lib/templates.tsx | 216 ----------------------------- src/lib/templates/Cinematic.tsx | 74 ++++++++++ src/lib/templates/Elegance.tsx | 66 +++++++++ src/lib/templates/Serene.tsx | 53 +++++++ src/lib/templates/index.tsx | 20 +++ src/lib/templates/shared.tsx | 22 +++ 10 files changed, 271 insertions(+), 232 deletions(-) delete mode 100644 src/lib/templates.tsx create mode 100644 src/lib/templates/Cinematic.tsx create mode 100644 src/lib/templates/Elegance.tsx create mode 100644 src/lib/templates/Serene.tsx create mode 100644 src/lib/templates/index.tsx create mode 100644 src/lib/templates/shared.tsx diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 3e8c1f6..bcf7975 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -6,6 +6,8 @@ import { UserButton } from "@clerk/nextjs"; import CopyButton from "@/components/CopyButton"; import DeleteMonumentButton from "@/components/DeleteMonumentButton"; import DeleteImageButton from "@/components/DeleteImageButton"; +import { generateMonumentQR } from "@/lib/qrcode"; +import { APP_DOMAIN } from "@/lib/config"; export default async function DashboardPage() { const { userId } = await auth(); @@ -20,7 +22,8 @@ export default async function DashboardPage() { 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 (
@@ -78,14 +81,15 @@ export default async function DashboardPage() {

QR код

Прикажете го овој QR код на споменикот за да можат посетителите да ја прочитаат приказната.

+ {/* eslint-disable-next-line @next/next/no-img-element */} QR код Превземи QR код diff --git a/src/components/ImageUploader.tsx b/src/components/ImageUploader.tsx index 9ad4b40..be8e4aa 100644 --- a/src/components/ImageUploader.tsx +++ b/src/components/ImageUploader.tsx @@ -2,10 +2,7 @@ import { useState } from "react"; import { useUser } from "@clerk/nextjs"; - -const MAX_FILES = 3; -const MAX_FILE_SIZE = 5 * 1024 * 1024; -const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]; +import { MAX_FILE_SIZE, ALLOWED_TYPES, MAX_FILES } from "@/lib/upload"; interface ImageData { key: string; @@ -31,7 +28,7 @@ export default function ImageUploader({ images, onImagesChange }: ImageUploaderP if (remaining <= 0) return; 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) .slice(0, remaining); diff --git a/src/components/SubdomainPicker.tsx b/src/components/SubdomainPicker.tsx index 79a6c99..99deee3 100644 --- a/src/components/SubdomainPicker.tsx +++ b/src/components/SubdomainPicker.tsx @@ -1,6 +1,11 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { + APP_DOMAIN, + SUBDOMAIN_MIN_LENGTH, + SUBDOMAIN_MAX_LENGTH, +} from "@/lib/config"; interface SubdomainPickerProps { 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 checkAvailability = useCallback(async (s: string) => { - if (s.length < 3) { + if (s.length < SUBDOMAIN_MIN_LENGTH) { setAvailable(null); 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, "-"))} 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} + maxLength={SUBDOMAIN_MAX_LENGTH} /> - .{process.env.NEXT_PUBLIC_APP_DOMAIN || "testbed.mk"} + .{APP_DOMAIN}
@@ -62,13 +67,13 @@ export default function SubdomainPicker({ value, onChange }: SubdomainPickerProp
{checking &&

Проверка на достапност...

} {!checking && available === true && ( -

✓ {slug}.testbed.mk е достапен!

+

✓ {slug}.{APP_DOMAIN} е достапен!

)} {!checking && available === false && (

✗ Овој поддомен е веќе зафатен.

)} - {!checking && available === null && slug.length > 0 && slug.length < 3 && ( -

Потребни се најмалку 3 карактери.

+ {!checking && available === null && slug.length > 0 && slug.length < SUBDOMAIN_MIN_LENGTH && ( +

Потребни се најмалку {SUBDOMAIN_MIN_LENGTH} карактери.

)}
diff --git a/src/lib/qrcode.ts b/src/lib/qrcode.ts index 0a083bd..62e864d 100644 --- a/src/lib/qrcode.ts +++ b/src/lib/qrcode.ts @@ -1,7 +1,8 @@ import QRCode from "qrcode"; +import { APP_DOMAIN } from "./config"; export async function generateMonumentQR(subdomain: string): Promise { - const url = `https://${subdomain}.${process.env.NEXT_PUBLIC_APP_DOMAIN}`; + const url = `https://${subdomain}.${APP_DOMAIN}`; const qrBuffer = await QRCode.toBuffer(url, { type: "png", width: 400, @@ -13,4 +14,17 @@ export async function generateMonumentQR(subdomain: string): Promise { }); return `data:image/png;base64,${qrBuffer.toString("base64")}`; +} + +export async function generateMonumentQRPng(subdomain: string): Promise { + const url = `https://${subdomain}.${APP_DOMAIN}`; + return QRCode.toBuffer(url, { + type: "png", + width: 400, + margin: 2, + color: { + dark: "#1a1a2e", + light: "#ffffff", + }, + }); } \ No newline at end of file diff --git a/src/lib/templates.tsx b/src/lib/templates.tsx deleted file mode 100644 index a932146..0000000 --- a/src/lib/templates.tsx +++ /dev/null @@ -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 ( -
-
- - - -
-

Во спомен на

- {name &&

{name}

} -
- ); -} - -export function renderTemplate(templateId: number, data: MemorialData) { - switch (templateId) { - case 1: - return ; - case 2: - return ; - case 3: - return ; - default: - return ; - } -} - -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 ( -
- {heroImage && ( -
- {data.title -
-
-

- {data.title || "Во спомен на"} -

- {dates && ( -

- {dates} -

- )} -
-
- )} - - {!heroImage && ( -
-

- {data.title || "Во спомен на"} -

- {dates && ( -

{dates}

- )} -
-
- )} - -
- {data.description && ( -
-

- {data.description} -

-
- )} - - {gridImages.length > 0 && ( -
- {gridImages.map((img) => ( -
- -
- ))} -
- )} - - -
-
- ); -} - -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 ( -
- {sortedImages.length > 0 && ( - <> -
- {data.title -
-
-

- {data.title || "Во спомен на"} -

- {dates && ( -

- {dates} -

- )} - {data.description && ( -

- {data.description} -

- )} -
-
- - {sortedImages.length > 1 && ( -
- {sortedImages.slice(1).map((img) => ( -
- -
- ))} -
- )} - - )} - - {sortedImages.length === 0 && ( -
-
-

- {data.title || "Во спомен на"} -

- {dates && ( -

{dates}

- )} - {data.description && ( -

- {data.description} -

- )} -
-
- )} - -
-
- - - -
-

Во спомен на

-
-
- ); -} - -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 ( -
-
-
- {sortedImages.length > 0 && ( -
- {data.title -
- )} - -

- {data.title || "Во спомен на"} -

- - {dates && ( -

{dates}

- )} - -
-
- - {data.description && ( -
-

- {data.description} -

-
- )} - - {sortedImages.length > 1 && ( -
- {sortedImages.slice(1).map((img) => ( -
- -
- ))} -
- )} - - -
-
- ); -} \ No newline at end of file diff --git a/src/lib/templates/Cinematic.tsx b/src/lib/templates/Cinematic.tsx new file mode 100644 index 0000000..8afd8ba --- /dev/null +++ b/src/lib/templates/Cinematic.tsx @@ -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 ( +
+ {sortedImages.length > 0 && ( + <> +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {data.title +
+
+

+ {data.title || "Во спомен на"} +

+ {dates && ( +

+ {dates} +

+ )} + {data.description && ( +

+ {data.description} +

+ )} +
+
+ + {sortedImages.length > 1 && ( +
+ {sortedImages.slice(1).map((img) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ ))} +
+ )} + + )} + + {sortedImages.length === 0 && ( +
+
+

+ {data.title || "Во спомен на"} +

+ {dates && ( +

{dates}

+ )} + {data.description && ( +

+ {data.description} +

+ )} +
+
+ )} + +
+
+ + + +
+

Во спомен на

+
+
+ ); +} diff --git a/src/lib/templates/Elegance.tsx b/src/lib/templates/Elegance.tsx new file mode 100644 index 0000000..5c0173a --- /dev/null +++ b/src/lib/templates/Elegance.tsx @@ -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 ( +
+ {heroImage && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {data.title +
+
+

+ {data.title || "Во спомен на"} +

+ {dates && ( +

+ {dates} +

+ )} +
+
+ )} + + {!heroImage && ( +
+

+ {data.title || "Во спомен на"} +

+ {dates && ( +

{dates}

+ )} +
+
+ )} + +
+ {data.description && ( +
+

+ {data.description} +

+
+ )} + + {gridImages.length > 0 && ( +
+ {gridImages.map((img) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ ))} +
+ )} + + +
+
+ ); +} diff --git a/src/lib/templates/Serene.tsx b/src/lib/templates/Serene.tsx new file mode 100644 index 0000000..55b6bcc --- /dev/null +++ b/src/lib/templates/Serene.tsx @@ -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 ( +
+
+
+ {sortedImages.length > 0 && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {data.title +
+ )} + +

+ {data.title || "Во спомен на"} +

+ + {dates && ( +

{dates}

+ )} + +
+
+ + {data.description && ( +
+

+ {data.description} +

+
+ )} + + {sortedImages.length > 1 && ( +
+ {sortedImages.slice(1).map((img) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ ))} +
+ )} + + +
+
+ ); +} diff --git a/src/lib/templates/index.tsx b/src/lib/templates/index.tsx new file mode 100644 index 0000000..de63361 --- /dev/null +++ b/src/lib/templates/index.tsx @@ -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 ; + case 2: + return ; + case 3: + return ; + default: + return ; + } +} diff --git a/src/lib/templates/shared.tsx b/src/lib/templates/shared.tsx new file mode 100644 index 0000000..ae75c1f --- /dev/null +++ b/src/lib/templates/shared.tsx @@ -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 ( + + ); +}