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 ( + + ); +}