feat: full site build — Project/Melody schema (Option A), admin CRUD, public sections, uploads, email+SMTP, internal analytics, legal pages, docs

This commit is contained in:
2026-08-05 20:53:40 +02:00
parent d87f3033c6
commit c26f41511f
122 changed files with 15068 additions and 41 deletions
+8
View File
@@ -0,0 +1,8 @@
export function trackClientEvent(input: { type: "PAGE_VIEW" | "PROJECT_OPEN" | "MELODY_PLAY" | "PROJECT_LINK_CLICK"; path?: string; entityId?: string }) {
void fetch("/api/analytics", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...input, path: input.path || window.location.pathname }),
keepalive: true,
}).catch(() => undefined);
}
+41
View File
@@ -0,0 +1,41 @@
import { prisma } from "@/lib/db";
import { analyticsEventSchema, type AnalyticsEventInput } from "@/lib/validations/analytics";
export async function recordAnalyticsEvent(input: unknown) {
const parsed = analyticsEventSchema.safeParse(input);
if (!parsed.success) return false;
const { type, path, entityId } = parsed.data;
await prisma.analyticsEvent.create({
data: {
type,
path: path || null,
projectId: type === "PROJECT_OPEN" || type === "PROJECT_LINK_CLICK" ? entityId || null : null,
melodyId: type === "MELODY_PLAY" ? entityId || null : null,
},
});
return true;
}
export async function getAnalyticsSummary() {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [total, grouped, recent] = await Promise.all([
prisma.analyticsEvent.count(),
prisma.analyticsEvent.groupBy({
by: ["type"],
where: { createdAt: { gte: since } },
_count: { _all: true },
orderBy: { _count: { type: "desc" } },
}),
prisma.analyticsEvent.findMany({
where: { createdAt: { gte: since } },
orderBy: { createdAt: "desc" },
take: 12,
select: { id: true, type: true, path: true, createdAt: true },
}),
]);
return { total, since, grouped, recent };
}
export type { AnalyticsEventInput };
+22
View File
@@ -0,0 +1,22 @@
import type { NextAuthConfig } from "next-auth";
const authConfig = {
providers: [],
pages: {
signIn: "/admin/login",
},
session: {
strategy: "jwt",
},
callbacks: {
authorized({ auth, request }) {
if (request.nextUrl.pathname === "/admin/login") {
return true;
}
return Boolean(auth?.user);
},
},
} satisfies NextAuthConfig;
export default authConfig;
+39
View File
@@ -0,0 +1,39 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { prisma } from "@/lib/db";
import authConfig from "@/lib/auth.config";
import { loginSchema } from "@/lib/validations/auth";
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: parsed.data.email },
});
if (!user || !(await compare(parsed.data.password, user.password))) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.email,
};
},
}),
],
});
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
export const db = prisma;
+55
View File
@@ -0,0 +1,55 @@
import nodemailer from "nodemailer";
import { render } from "@react-email/render";
import ContactMessageEmail, { ContactConfirmationEmail } from "@/components/emails/contact-message";
type ContactEmailData = { name: string; email: string; message: string };
function getTransporter() {
const port = Number(process.env.SMTP_PORT || 587);
return nodemailer.createTransport({
host: process.env.SMTP_HOST,
port,
secure: process.env.SMTP_SECURE === "true" || port === 465,
auth: process.env.SMTP_USER && process.env.SMTP_PASSWORD
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
: undefined,
});
}
function getEmailConfig() {
const from = process.env.SMTP_FROM;
const to = process.env.SMTP_TO || process.env.NEXT_PUBLIC_CONTACT_EMAIL;
if (!process.env.SMTP_HOST || !from || !to) throw new Error("SMTP email configuration is incomplete.");
return { from, to };
}
function getPlainText({ name, email, message }: ContactEmailData) {
return [`New contact message`, ``, `Name: ${name}`, `Email: ${email}`, ``, message].join("\n");
}
function getConfirmationText(name: string, siteUrl: string) {
return [`Hi ${name},`, ``, `Your message was received. I will get back to you as soon as possible.`, ``, siteUrl].join("\n");
}
export async function sendContactMessageEmail(data: ContactEmailData) {
const { from, to } = getEmailConfig();
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
const html = await render(ContactMessageEmail({ ...data, siteUrl }));
const transporter = getTransporter();
await transporter.sendMail({
from,
to,
replyTo: data.email,
subject: `New contact message from ${data.name}`,
text: getPlainText(data),
html,
});
const confirmationHtml = await render(ContactConfirmationEmail({ name: data.name, siteUrl }));
await transporter.sendMail({
from,
to: data.email,
subject: "Thanks for contacting Diyaa",
text: getConfirmationText(data.name, siteUrl),
html: confirmationHtml,
});
}
+3 -3
View File
@@ -1,7 +1,7 @@
import ar from "@/content/ar";
import en from "@/content/en";
import type { Dictionary } from "@/content/types";
import { isComingSoonMode } from "@/lib/site";
import { isSiteClosedMode } from "@/lib/site";
export const locales = ["ar", "en"] as const;
@@ -13,11 +13,11 @@ const dictionaries: Record<Locale, Dictionary> = {
};
export function getActiveLocale(locale: Locale): Locale {
return isComingSoonMode() ? "en" : locale;
return isSiteClosedMode() ? "en" : locale;
}
export function getEnabledLocales(): Locale[] {
return isComingSoonMode() ? ["en"] : [...locales];
return isSiteClosedMode() ? ["en"] : [...locales];
}
export function isLocale(value: string): value is Locale {
+102
View File
@@ -0,0 +1,102 @@
import type { Locale } from "@/lib/i18n";
import { legalConfig, siteConfig } from "@/lib/site";
export type LegalSection = { heading: string; paragraphs?: string[]; list?: string[] };
export type LegalDocument = { title: string; intro: string; updatedLabel: string; sections: LegalSection[] };
export type LegalPage = "privacy" | "terms" | "impressum";
function configured(value: string, fallback: string) {
return value || fallback;
}
export function getLegalDocument(locale: Locale, page: LegalPage): LegalDocument {
const isArabic = locale === "ar";
const email = configured(siteConfig.email, isArabic ? "أضف البريد في ملف البيئة" : "Configure the contact email in the environment");
const address = [legalConfig.address, [legalConfig.postalCode, legalConfig.city].filter(Boolean).join(" "), legalConfig.country].filter(Boolean);
const addressText = configured(address.join(", "), isArabic ? "أضف العنوان القانوني في ملف البيئة" : "Configure the legal address in the environment");
const owner = legalConfig.name;
const lastUpdated = "2026-08-05";
const documents: Record<Locale, Record<LegalPage, LegalDocument>> = {
ar: {
privacy: {
title: "سياسة الخصوصية",
intro: "توضح هذه الصفحة كيف يتعامل موقع ضياء مع البيانات التي تُرسل من خلال نموذج التواصل والبيانات التقنية اللازمة لتشغيل الموقع.",
updatedLabel: `آخر تحديث: ${lastUpdated}`,
sections: [
{ heading: "المسؤول عن المعالجة", paragraphs: [`${owner}، ${addressText}`, `البريد الإلكتروني: ${email}`] },
{ heading: "البيانات التي نجمعها", paragraphs: ["عند استخدام نموذج التواصل قد نعالج الاسم والبريد الإلكتروني ومحتوى الرسالة. قد يعالج الخادم أيضاً بيانات تقنية ضرورية لتقديم الموقع وحمايته من إساءة الاستخدام."] },
{ heading: "الغرض والأساس القانوني", paragraphs: ["نستخدم بيانات نموذج التواصل للرد على الاستفسارات وتنفيذ الخطوات المطلوبة قبل التعاقد عند الاقتضاء. يكون الأساس القانوني عادةً تنفيذ طلبك أو مصلحتنا المشروعة في إدارة التواصل المهني، بحسب طبيعة الرسالة."] },
{ heading: "الإرسال والتخزين", paragraphs: ["تُحفظ رسائل التواصل في قاعدة بيانات الموقع، ويُرسل إشعار بها عبر مزود SMTP مضبوط في إعدادات الخادم. لا نبيع البيانات ولا نستخدمها للإعلانات، ونحتفظ بها فقط للمدة اللازمة لمعالجة الطلب والالتزامات القانونية."] },
{ heading: "الحماية من الرسائل المزعجة", paragraphs: ["يستخدم النموذج حقلاً مخفياً وتحديداً مؤقتاً لمعدل الطلبات. لا يُستخدم عنوان المصدر المحسوب للحماية كجزء من ملف تسويقي."] },
{ heading: "حقوقك", paragraphs: ["بحسب القوانين المعمول بها، قد يحق لك طلب الوصول إلى بياناتك أو تصحيحها أو حذفها أو تقييد معالجتها أو الاعتراض عليها، كما يمكنك تقديم شكوى إلى جهة حماية البيانات المختصة. تواصل معنا عبر البريد أعلاه لممارسة حق من هذه الحقوق."] },
{ heading: "التحديثات", paragraphs: ["قد نحدّث هذه السياسة عند تغيير الموقع أو طريقة معالجة البيانات. سيظهر تاريخ آخر تحديث في أعلى الصفحة."] },
],
},
terms: {
title: "شروط الاستخدام",
intro: "تحدد هذه الشروط الإطار العام لاستخدام موقع ضياء ومحتواه العام.",
updatedLabel: `آخر تحديث: ${lastUpdated}`,
sections: [
{ heading: "نطاق الاستخدام", paragraphs: ["يمكنك تصفح الموقع واستخدام محتواه لأغراض شخصية أو مهنية مشروعة. لا يجوز استخدام الموقع بطريقة تضر به أو تتجاوز الضوابط التقنية أو تنتهك حقوق الآخرين."] },
{ heading: "المحتوى وحقوق الملكية", paragraphs: ["ما لم يُذكر خلاف ذلك، تعود حقوق النصوص والصور والتصاميم والمشاريع المعروضة إلى مالكها أو أصحاب الحقوق المعنيين. لا يجوز نسخ المحتوى أو إعادة نشره أو استخدامه تجارياً دون إذن مناسب."] },
{ heading: "التوفر والروابط الخارجية", paragraphs: ["نسعى إلى إبقاء الموقع متاحاً ودقيقاً، لكن لا نضمن التوفر الدائم أو خلو المحتوى من الأخطاء. قد يحتوي الموقع على روابط لخدمات خارجية؛ تقع مسؤولية استخدام تلك الخدمات على مشغليها."] },
{ heading: "التواصل", paragraphs: [`للاستفسارات حول هذه الشروط تواصل مع ${owner} عبر ${email}.`] },
{ heading: "التعديلات", paragraphs: ["يجوز تحديث هذه الشروط عند تطور الموقع أو الخدمات. تسري النسخة المنشورة على الاستخدام اللاحق للموقع."] },
],
},
impressum: {
title: "Impressum · بيانات المزوّد",
intro: "بيانات المزوّد المطلوبة للموقع المهني. يجب إكمال الحقول من ملف البيئة قبل النشر العام.",
updatedLabel: `آخر تحديث: ${lastUpdated}`,
sections: [
{ heading: "المزوّد", paragraphs: [owner, addressText] },
{ heading: "التواصل المباشر", paragraphs: [`البريد الإلكتروني: ${email}`] },
{ heading: "بيانات إضافية", paragraphs: [legalConfig.vatId ? `رقم ضريبة القيمة المضافة: ${legalConfig.vatId}` : "لم يتم ضبط رقم ضريبة القيمة المضافة في ملف البيئة."] },
{ heading: "تنبيه الإكمال", paragraphs: ["تحقق من الاسم والعنوان والبيانات المهنية الفعلية مع مستشارك القانوني قبل إطلاق الموقع. هذه الصفحة قالب تقني وليست استشارة قانونية."] },
],
},
},
en: {
privacy: {
title: "Privacy policy",
intro: "This page explains how Diyaa handles data submitted through the contact form and the technical data needed to operate the website.",
updatedLabel: `Last updated: ${lastUpdated}`,
sections: [
{ heading: "Controller", paragraphs: [`${owner}, ${addressText}`, `Email: ${email}`] },
{ heading: "Data we collect", paragraphs: ["When you use the contact form, we may process your name, email address, and message. The server may also process technical data necessary to deliver the site and prevent abuse."] },
{ heading: "Purpose and legal basis", paragraphs: ["We use contact-form data to respond to inquiries and, where applicable, take steps at your request before entering into a contract. The legal basis depends on the nature of the inquiry and may be contract-related processing or our legitimate interest in professional communication."] },
{ heading: "Delivery and storage", paragraphs: ["Contact messages are stored in the website database and an email notification is sent through the SMTP provider configured on the server. We do not sell the data or use it for advertising, and retain it only as long as needed to handle the inquiry and meet legal obligations."] },
{ heading: "Spam protection", paragraphs: ["The form uses a hidden honeypot field and temporary request throttling. The source address used for this protection is not used as a marketing profile."] },
{ heading: "Your rights", paragraphs: ["Depending on applicable law, you may have rights to access, correct, delete, restrict, or object to processing of your data, and to complain to a competent data-protection authority. Contact us at the email above to exercise a right."] },
{ heading: "Updates", paragraphs: ["We may update this policy when the website or its processing practices change. The latest update date is shown above."] },
],
},
terms: {
title: "Terms of use",
intro: "These terms provide the general framework for using the Diyaa website and its public content.",
updatedLabel: `Last updated: ${lastUpdated}`,
sections: [
{ heading: "Permitted use", paragraphs: ["You may browse and use the website for lawful personal or professional purposes. You must not use it in a way that harms the site, bypasses technical safeguards, or infringes the rights of others."] },
{ heading: "Content and intellectual property", paragraphs: ["Unless stated otherwise, texts, images, designs, and showcased projects belong to their owner or the respective rights holders. Do not copy, republish, or commercially use content without appropriate permission."] },
{ heading: "Availability and external links", paragraphs: ["We aim to keep the website available and accurate, but do not guarantee uninterrupted availability or error-free content. External links lead to services operated by third parties, whose operators are responsible for their own services."] },
{ heading: "Contact", paragraphs: [`For questions about these terms, contact ${owner} at ${email}.`] },
{ heading: "Changes", paragraphs: ["These terms may be updated as the website or services evolve. The published version applies to future use of the website."] },
],
},
impressum: {
title: "Legal notice",
intro: "Provider information for this professional website. Complete the environment fields before public launch.",
updatedLabel: `Last updated: ${lastUpdated}`,
sections: [
{ heading: "Provider", paragraphs: [owner, addressText] },
{ heading: "Direct contact", paragraphs: [`Email: ${email}`] },
{ heading: "Additional information", paragraphs: [legalConfig.vatId ? `VAT ID: ${legalConfig.vatId}` : "No VAT ID has been configured in the environment."] },
{ heading: "Completion notice", paragraphs: ["Verify the actual name, address, and professional details with your legal adviser before launching the website. This page is a technical template, not legal advice."] },
],
},
},
};
return documents[locale][page];
}
+36
View File
@@ -0,0 +1,36 @@
import type { Locale } from "@/lib/i18n";
const directoryCopy = {
ar: {
segment: "melodies",
eyebrow: "الألحان",
title: "ألحان ومقاطع صوتية مختارة",
description: "استمع إلى مجموعة من الألحان والمقاطع الصوتية المنشورة.",
empty: "لا توجد ألحان منشورة ضمن هذا التصنيف بعد.",
filterLabel: "فلترة الألحان حسب التصنيف",
all: "الكل",
play: "تشغيل",
download: "تحميل الصوت",
back: "العودة إلى الألحان",
},
en: {
segment: "melodies",
eyebrow: "Melodies",
title: "Selected melodies and audio pieces",
description: "Listen to a collection of published melodies and audio pieces.",
empty: "No published melodies are available in this category yet.",
filterLabel: "Filter melodies by category",
all: "All",
play: "Play",
download: "Download audio",
back: "Back to melodies",
},
} as const;
export function getMelodyDirectoryCopy(locale: Locale) {
return directoryCopy[locale];
}
export function getMelodyPath(locale: Locale, slug?: string) {
return `/${locale}/melodies${slug ? `/${slug}` : ""}`;
}
+27
View File
@@ -0,0 +1,27 @@
import { prisma } from "@/lib/db";
export async function getPublishedMelodies(categorySlug?: string) {
return prisma.melody.findMany({
where: {
status: "PUBLISHED",
category: categorySlug ? { slug: categorySlug, kind: "MELODY" } : undefined,
},
orderBy: [{ isFeatured: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }, { titleEn: "asc" }],
include: { category: { select: { slug: true, nameAr: true, nameEn: true } } },
});
}
export async function getMelodyCategories() {
return prisma.category.findMany({
where: { kind: "MELODY" },
orderBy: [{ order: "asc" }, { nameEn: "asc" }],
select: { id: true, slug: true, nameAr: true, nameEn: true, _count: { select: { melodies: true } } },
});
}
export async function getPublishedMelody(slug: string) {
return prisma.melody.findFirst({
where: { slug, status: "PUBLISHED" },
include: { category: { select: { slug: true, nameAr: true, nameEn: true } } },
});
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import { getActiveLocale, getDictionary, type Locale } from "@/lib/i18n";
import { getLocalizedPath, getLocalizedUrl, getModeValue, isComingSoonMode } from "@/lib/site";
import { getLocalizedPath, getLocalizedUrl, getModeValue, isSiteClosedMode } from "@/lib/site";
type PageKey = "home" | "about" | "contact";
@@ -31,8 +31,8 @@ export function buildPageMetadata(locale: Locale, page: PageKey): Metadata {
} as const;
const pageMetadata = metadataByPage[page];
const canonicalPath = isComingSoonMode() && page === "home" ? "/" : getLocalizedPath(pathname, activeLocale);
const alternates = isComingSoonMode()
const canonicalPath = isSiteClosedMode() && page === "home" ? "/" : getLocalizedPath(pathname, activeLocale);
const alternates = isSiteClosedMode()
? {
canonical: canonicalPath,
languages: {
@@ -56,7 +56,7 @@ export function buildPageMetadata(locale: Locale, page: PageKey): Metadata {
openGraph: {
title: pageMetadata.title,
description: pageMetadata.description,
url: isComingSoonMode() && page === "home" ? getLocalizedUrl("/", "en") : getLocalizedUrl(pathname, activeLocale),
url: isSiteClosedMode() && page === "home" ? getLocalizedUrl("/", "en") : getLocalizedUrl(pathname, activeLocale),
siteName: dictionary.common.siteTitle,
locale: activeLocale === "ar" ? "ar_SA" : "en_US",
type: "website",
+84
View File
@@ -0,0 +1,84 @@
import type { ProjectType } from "@prisma/client";
import type { Locale } from "@/lib/i18n";
export const publicProjectTypes: ProjectType[] = ["PORTFOLIO", "APP", "WEBSITE", "DESIGN"];
const directoryCopy = {
ar: {
PORTFOLIO: {
segment: "work",
eyebrow: "الأعمال",
title: "مختارات من المشاريع والأعمال",
description: "مشاريع مختارة تجمع بين الفكرة، التصميم، والتنفيذ العملي.",
empty: "لا توجد أعمال منشورة ضمن هذا التصنيف بعد.",
},
WEBSITE: {
segment: "websites",
eyebrow: "المواقع",
title: "مواقع جاهزة للنشر والنمو",
description: "مواقع مبنية بهيكل واضح وتجربة استخدام متماسكة.",
empty: "لا توجد مواقع منشورة ضمن هذا التصنيف بعد.",
},
DESIGN: {
segment: "designs",
eyebrow: "التصاميم",
title: "هويات وتصاميم بصرية",
description: "أعمال بصرية تركز على الوضوح، الهوية، وقابلية الاستخدام.",
empty: "لا توجد تصاميم منشورة ضمن هذا التصنيف بعد.",
},
APP: {
segment: "apps",
eyebrow: "التطبيقات",
title: "تطبيقات ومنتجات رقمية",
description: "تجارب تطبيقات ومنتجات مبنية بعناية.",
empty: "لا توجد تطبيقات منشورة ضمن هذا التصنيف بعد.",
},
},
en: {
PORTFOLIO: {
segment: "work",
eyebrow: "Portfolio",
title: "Selected work and projects",
description: "A selection of projects balancing ideas, design, and practical delivery.",
empty: "No published work is available in this category yet.",
},
WEBSITE: {
segment: "websites",
eyebrow: "Websites",
title: "Websites built to launch and grow",
description: "Web experiences with clear structure and a cohesive user journey.",
empty: "No published websites are available in this category yet.",
},
DESIGN: {
segment: "designs",
eyebrow: "Designs",
title: "Visual identities and designs",
description: "Visual work focused on clarity, identity, and usefulness.",
empty: "No published designs are available in this category yet.",
},
APP: {
segment: "apps",
eyebrow: "Apps",
title: "Apps and digital products",
description: "Carefully built application and product experiences.",
empty: "No published apps are available in this category yet.",
},
},
} as const;
export function getProjectDirectoryCopy(locale: Locale, type: ProjectType) {
return directoryCopy[locale][type];
}
export function getProjectPath(locale: Locale, type: ProjectType, slug?: string) {
const segment = getProjectDirectoryCopy(locale, type).segment;
return `/${locale}/${segment}${slug ? `/${slug}` : ""}`;
}
export function getProjectTypeFromSegment(segment: string): ProjectType | null {
if (segment === "work") return "PORTFOLIO";
if (segment === "apps") return "APP";
if (segment === "websites") return "WEBSITE";
if (segment === "designs") return "DESIGN";
return null;
}
+39
View File
@@ -0,0 +1,39 @@
import type { ProjectType } from "@prisma/client";
import { prisma } from "@/lib/db";
export async function getPublishedProjects(type: ProjectType, categorySlug?: string) {
return prisma.project.findMany({
where: {
type,
status: "PUBLISHED",
category: categorySlug ? { slug: categorySlug, kind: "PROJECT" } : undefined,
},
orderBy: [{ isFeatured: "desc" }, { sortOrder: "asc" }, { publishedAt: "desc" }, { titleEn: "asc" }],
include: {
category: { select: { slug: true, nameAr: true, nameEn: true } },
},
});
}
export async function getProjectCategories() {
return prisma.category.findMany({
where: { kind: "PROJECT" },
orderBy: [{ order: "asc" }, { nameEn: "asc" }],
select: {
id: true,
slug: true,
nameAr: true,
nameEn: true,
_count: { select: { projects: true } },
},
});
}
export async function getPublishedProject(type: ProjectType, slug: string) {
return prisma.project.findFirst({
where: { type, slug, status: "PUBLISHED" },
include: {
category: { select: { slug: true, nameAr: true, nameEn: true } },
},
});
}
+26
View File
@@ -0,0 +1,26 @@
type RateLimitEntry = { count: number; resetAt: number };
const entries = new Map<string, RateLimitEntry>();
const windowMs = 10 * 60 * 1000;
const maxRequests = 5;
export function consumeRateLimit(key: string, limit: number, durationMs: number) {
const now = Date.now();
const current = entries.get(key);
if (!current || current.resetAt <= now) {
entries.set(key, { count: 1, resetAt: now + durationMs });
return true;
}
if (current.count >= limit) return false;
current.count += 1;
return true;
}
export function consumeContactRateLimit(key: string) {
return consumeRateLimit(key, maxRequests, windowMs);
}
export function getClientIp(requestHeaders: Headers) {
const forwardedFor = requestHeaders.get("x-forwarded-for")?.split(",")[0]?.trim();
return forwardedFor || requestHeaders.get("x-real-ip")?.trim() || "unknown";
}
+19 -2
View File
@@ -1,7 +1,7 @@
import type { ContactContent, ModeVariants } from "@/content/types";
const FALLBACK_SITE_URL = "https://example.com";
const SITE_MODES = ["coming-soon", "full"] as const;
const SITE_MODES = ["coming-soon", "maintenance", "full"] as const;
export type SiteMode = (typeof SITE_MODES)[number];
@@ -51,6 +51,15 @@ export const siteConfig = {
githubUrl: normalizeExternalUrl(process.env.NEXT_PUBLIC_GITHUB_URL),
};
export const legalConfig = {
name: process.env.LEGAL_NAME?.trim() || siteConfig.ownerName,
address: process.env.LEGAL_ADDRESS?.trim() || "",
postalCode: process.env.LEGAL_POSTAL_CODE?.trim() || "",
city: process.env.LEGAL_CITY?.trim() || "",
country: process.env.LEGAL_COUNTRY?.trim() || "",
vatId: process.env.LEGAL_VAT_ID?.trim() || "",
};
export function getSiteMode(): SiteMode {
const mode = process.env.NEXT_PUBLIC_SITE_MODE?.trim();
return SITE_MODES.includes(mode as SiteMode) ? (mode as SiteMode) : "coming-soon";
@@ -60,8 +69,16 @@ export function isComingSoonMode(): boolean {
return getSiteMode() === "coming-soon";
}
export function isMaintenanceMode(): boolean {
return getSiteMode() === "maintenance";
}
export function isSiteClosedMode(): boolean {
return getSiteMode() !== "full";
}
function getModeVariantKey(mode: SiteMode): keyof ModeVariants<unknown> {
return mode === "coming-soon" ? "comingSoon" : "full";
return mode === "full" ? "full" : "comingSoon";
}
export function getModeValue<T>(variants: ModeVariants<T>): T {
+156
View File
@@ -0,0 +1,156 @@
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import sharp from "sharp";
import {
isAllowedUploadMimeType,
uploadRequestSchema,
UPLOAD_RULES,
type UploadKind,
type UploadedFileMetadata,
} from "@/lib/validations/upload";
const imageExtension = ".webp";
const audioExtensions: Record<string, string> = {
"audio/aac": ".aac",
"audio/flac": ".flac",
"audio/mp4": ".m4a",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"audio/wav": ".wav",
"audio/webm": ".webm",
"audio/x-wav": ".wav",
};
const mimeTypesByExtension: Record<string, string> = {
".aac": "audio/aac",
".flac": "audio/flac",
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".ogg": "audio/ogg",
".wav": "audio/wav",
".webm": "audio/webm",
".webp": "image/webp",
};
export class UploadError extends Error {
statusCode: 400 | 404 | 413 | 500;
constructor(message: string, statusCode: 400 | 404 | 413 | 500 = 400) {
super(message);
this.name = "UploadError";
this.statusCode = statusCode;
}
}
export function getUploadRoot() {
return path.resolve(process.env.UPLOAD_DIR ?? path.join(process.cwd(), "uploads"));
}
function getKindDirectory(kind: UploadKind) {
return path.join(getUploadRoot(), `${kind}s`);
}
function getPublicUrl(kind: UploadKind, fileName: string) {
return `/api/uploads/${kind}s/${fileName}`;
}
function validateUpload(kind: UploadKind, file: File) {
const parsed = uploadRequestSchema.safeParse({
kind,
fileName: file.name,
mimeType: file.type,
size: file.size,
});
if (!parsed.success) {
throw new UploadError("Invalid upload metadata.");
}
const rules = UPLOAD_RULES[kind];
if (!isAllowedUploadMimeType(kind, parsed.data.mimeType)) {
throw new UploadError(`Unsupported ${kind} file type.`);
}
if (parsed.data.size > rules.maxBytes) {
throw new UploadError(`The ${kind} file is too large.`, 413);
}
return parsed.data;
}
export async function saveUploadedFile(file: File, kind: UploadKind): Promise<UploadedFileMetadata> {
const metadata = validateUpload(kind, file);
const source = Buffer.from(await file.arrayBuffer());
const id = randomUUID();
if (kind === "image") {
const fileName = `${id}${imageExtension}`;
const output = await sharp(source)
.rotate()
.resize({ width: 2400, height: 2400, fit: "inside", withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer()
.catch(() => {
throw new UploadError("The image could not be processed.");
});
await mkdir(getKindDirectory(kind), { recursive: true });
await writeFile(path.join(getKindDirectory(kind), fileName), output, { flag: "wx" });
return {
kind,
url: getPublicUrl(kind, fileName),
fileName,
mimeType: "image/webp",
size: output.byteLength,
};
}
const extension = audioExtensions[metadata.mimeType.toLowerCase()];
if (!extension) {
throw new UploadError("Unsupported audio file type.");
}
const fileName = `${id}${extension}`;
await mkdir(getKindDirectory(kind), { recursive: true });
await writeFile(path.join(getKindDirectory(kind), fileName), source, { flag: "wx" });
return {
kind,
url: getPublicUrl(kind, fileName),
fileName,
mimeType: metadata.mimeType,
size: source.byteLength,
};
}
export async function readUploadedFile(segments: string[]) {
const root = getUploadRoot();
const filePath = path.resolve(root, ...segments);
const rootPrefix = `${root}${path.sep}`;
if (!filePath.startsWith(rootPrefix)) {
throw new UploadError("File not found.", 404);
}
try {
const fileInfo = await stat(filePath);
if (!fileInfo.isFile()) {
throw new UploadError("File not found.", 404);
}
const extension = path.extname(filePath).toLowerCase();
return {
data: await readFile(filePath),
mimeType: mimeTypesByExtension[extension] ?? "application/octet-stream",
};
} catch (error) {
if (error instanceof UploadError) {
throw error;
}
throw new UploadError("File not found.", 404);
}
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+17
View File
@@ -0,0 +1,17 @@
import { z } from "zod";
export const analyticsEventTypeSchema = z.enum([
"PAGE_VIEW",
"PROJECT_OPEN",
"MELODY_PLAY",
"PROJECT_LINK_CLICK",
"CONTACT_SUBMITTED",
]);
export const analyticsEventSchema = z.object({
type: analyticsEventTypeSchema,
path: z.string().trim().max(2048).optional(),
entityId: z.string().cuid().optional(),
});
export type AnalyticsEventInput = z.infer<typeof analyticsEventSchema>;
+8
View File
@@ -0,0 +1,8 @@
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().trim().email("Enter a valid email address.").max(254).transform((value) => value.toLowerCase()),
password: z.string().min(12, "Password must be at least 12 characters.").max(128),
});
export type LoginInput = z.infer<typeof loginSchema>;
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod";
export const categoryKindSchema = z.enum(["PROJECT", "MELODY"]);
export const categorySchema = z.object({
kind: categoryKindSchema,
slug: z
.string()
.trim()
.min(1, "Slug is required.")
.max(80, "Slug must be 80 characters or fewer.")
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens only."),
nameAr: z.string().trim().min(1, "Arabic name is required.").max(120),
nameEn: z.string().trim().min(1, "English name is required.").max(120),
order: z.number().int().min(0).max(9999),
});
export const categoryIdSchema = z.string().cuid("Invalid category id.");
export type CategoryInput = z.infer<typeof categorySchema>;
+10
View File
@@ -0,0 +1,10 @@
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().trim().min(2).max(100),
email: z.string().trim().email().max(254),
message: z.string().trim().min(10).max(5000),
website: z.string().max(0).optional(),
});
export type ContactInput = z.infer<typeof contactSchema>;
+29
View File
@@ -0,0 +1,29 @@
import { z } from "zod";
export const melodyStatusSchema = z.enum(["DRAFT", "PUBLISHED", "ARCHIVED"]);
const audioPath = z.string().trim().regex(/^\/api\/uploads\/audios\/[^/]+$/, "Use an uploaded audio path.");
const imagePath = z.string().trim().regex(/^\/api\/uploads\/images\/[^/]+$/, "Use an uploaded image path.");
export const melodySchema = z.object({
slug: z
.string()
.trim()
.min(1, "Slug is required.")
.max(100, "Slug must be 100 characters or fewer.")
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens only."),
titleAr: z.string().trim().min(1, "Arabic title is required.").max(160),
titleEn: z.string().trim().min(1, "English title is required.").max(160),
descAr: z.string().trim().max(4000).optional(),
descEn: z.string().trim().max(4000).optional(),
audioFile: audioPath,
coverImage: z.union([z.literal(""), imagePath]).optional(),
durationSec: z.number().int().min(0).max(86400).nullable(),
isDownloadable: z.boolean(),
status: melodyStatusSchema,
isFeatured: z.boolean(),
sortOrder: z.number().int().min(0).max(9999),
categoryId: z.string().cuid("Choose a valid melody category."),
});
export const melodyIdSchema = z.string().cuid("Invalid melody id.");
export type MelodyInput = z.infer<typeof melodySchema>;
+46
View File
@@ -0,0 +1,46 @@
import { z } from "zod";
export const projectTypeSchema = z.enum(["PORTFOLIO", "APP", "WEBSITE", "DESIGN"]);
export const publishStatusSchema = z.enum(["DRAFT", "PUBLISHED", "ARCHIVED"]);
const optionalText = (max: number) => z.string().trim().max(max).optional();
const optionalUrl = z.union([z.literal(""), z.string().trim().url().max(500)]).optional();
const mediaPath = z.string().trim().regex(/^\/api\/uploads\/(images|audios)\/[^/]+$/, "Use an uploaded media path.");
const optionalMediaPath = z.union([z.literal(""), mediaPath]).optional();
export const projectSchema = z.object({
type: projectTypeSchema,
slug: z
.string()
.trim()
.min(1, "Slug is required.")
.max(100, "Slug must be 100 characters or fewer.")
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Use lowercase letters, numbers, and hyphens only."),
titleAr: z.string().trim().min(1, "Arabic title is required.").max(160),
titleEn: z.string().trim().min(1, "English title is required.").max(160),
summaryAr: optionalText(300),
summaryEn: optionalText(300),
descAr: optionalText(4000),
descEn: optionalText(4000),
coverImage: optionalMediaPath,
images: z.array(mediaPath).max(50, "You can add up to 50 images."),
technologies: z.array(z.string().trim().min(1).max(60)).max(30, "You can add up to 30 technologies."),
externalUrl: optionalUrl,
repoUrl: optionalUrl,
platform: optionalText(120),
appStoreUrl: optionalUrl,
testflightUrl: optionalUrl,
appVersion: optionalText(80),
supportUrl: optionalUrl,
appPrivacyUrl: optionalUrl,
status: publishStatusSchema,
isFeatured: z.boolean(),
sortOrder: z.number().int().min(0).max(9999),
categoryId: z.union([z.literal(""), z.string().cuid()]).optional(),
});
export const projectIdSchema = z.string().cuid("Invalid project id.");
export const projectTypeFilterSchema = projectTypeSchema;
export type ProjectInput = z.infer<typeof projectSchema>;
export type ProjectTypeFilter = z.infer<typeof projectTypeFilterSchema>;
+56
View File
@@ -0,0 +1,56 @@
import { z } from "zod";
export const uploadKindSchema = z.enum(["image", "audio"]);
export type UploadKind = z.infer<typeof uploadKindSchema>;
export const uploadRequestSchema = z.object({
kind: uploadKindSchema,
fileName: z.string().trim().min(1).max(255),
mimeType: z.string().trim().min(1).max(120),
size: z.number().int().positive(),
});
export const uploadedFileSchema = z.object({
kind: uploadKindSchema,
url: z.string().startsWith("/api/uploads/"),
fileName: z.string().min(1),
mimeType: z.string().min(1),
size: z.number().int().positive(),
});
export type UploadRequest = z.infer<typeof uploadRequestSchema>;
export type UploadedFileMetadata = z.infer<typeof uploadedFileSchema>;
export const UPLOAD_RULES: Record<
UploadKind,
{
maxBytes: number;
accept: string;
mimeTypes: readonly string[];
}
> = {
image: {
maxBytes: 10 * 1024 * 1024,
accept: "image/jpeg,image/png,image/webp",
mimeTypes: ["image/jpeg", "image/png", "image/webp"],
},
audio: {
maxBytes: 50 * 1024 * 1024,
accept: "audio/mpeg,audio/wav,audio/x-wav,audio/ogg,audio/webm,audio/mp4,audio/aac,audio/flac",
mimeTypes: [
"audio/mpeg",
"audio/wav",
"audio/x-wav",
"audio/ogg",
"audio/webm",
"audio/mp4",
"audio/aac",
"audio/flac",
],
},
};
export function isAllowedUploadMimeType(kind: UploadKind, mimeType: string) {
return UPLOAD_RULES[kind].mimeTypes.includes(mimeType.toLowerCase());
}