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
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { getActiveLocale, getDictionary, isLocale, type Locale } from "@/lib/i18n";
import { buildPageMetadata } from "@/lib/metadata";
import { isComingSoonMode } from "@/lib/site";
import { isSiteClosedMode } from "@/lib/site";
import { notFound, redirect } from "next/navigation";
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
@@ -17,7 +17,7 @@ export default function AboutPage({ params }: { params: { locale: string } }) {
notFound();
}
if (isComingSoonMode()) {
if (isSiteClosedMode()) {
redirect("/");
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDetail from "@/components/public/project-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
import { getPublishedProject } from "@/lib/project-queries";
type AppDetailPageProps = {
params: { locale: string; slug: string };
};
export function generateMetadata({ params }: AppDetailPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "APP");
return { title: copy.eyebrow, description: copy.description };
}
export default async function AppDetailPage({ params }: AppDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const project = await getPublishedProject("APP", params.slug);
if (!project) notFound();
return <ProjectDetail locale={locale} type="APP" project={project} />;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDirectory from "@/components/public/project-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
type AppsPageProps = {
params: { locale: string };
searchParams: { category?: string };
};
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "APP");
return { title: copy.title, description: copy.description };
}
export default function AppsPage({ params, searchParams }: AppsPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <ProjectDirectory locale={locale} type="APP" categorySlug={searchParams.category} />;
}
+39
View File
@@ -0,0 +1,39 @@
"use server";
import { headers } from "next/headers";
import { prisma } from "@/lib/db";
import { recordAnalyticsEvent } from "@/lib/analytics";
import { sendContactMessageEmail } from "@/lib/email";
import { consumeContactRateLimit, getClientIp } from "@/lib/rate-limit";
import { contactSchema } from "@/lib/validations/contact";
export type ContactActionResult = {
success?: true;
warning?: "email-not-sent";
error?: "invalid" | "rate-limit";
};
export async function submitContactMessage(input: unknown): Promise<ContactActionResult> {
const parsed = contactSchema.safeParse(input);
if (!parsed.success) return { error: "invalid" };
if (parsed.data.website) return { success: true };
const ip = getClientIp(headers());
if (!consumeContactRateLimit(`contact:${ip}`)) return { error: "rate-limit" };
const { name, email, message } = parsed.data;
await prisma.contactMessage.create({ data: { name, email, message } });
try {
await recordAnalyticsEvent({ type: "CONTACT_SUBMITTED", path: "/contact" });
} catch (error) {
console.error("Contact analytics failed", error);
}
try {
await sendContactMessageEmail({ name, email, message });
return { success: true };
} catch (error) {
console.error("Contact email delivery failed", error);
return { success: true, warning: "email-not-sent" };
}
}
+17 -2
View File
@@ -1,8 +1,9 @@
import type { Metadata } from "next";
import { getActiveLocale, getDictionary, isLocale, type Locale } from "@/lib/i18n";
import { buildPageMetadata } from "@/lib/metadata";
import { getContactChannels, isComingSoonMode } from "@/lib/site";
import { getContactChannels, isSiteClosedMode } from "@/lib/site";
import { notFound, redirect } from "next/navigation";
import ContactForm from "@/components/public/contact-form";
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) {
@@ -17,7 +18,7 @@ export default function ContactPage({ params }: { params: { locale: string } })
notFound();
}
if (isComingSoonMode()) {
if (isSiteClosedMode()) {
redirect("/");
}
@@ -38,6 +39,20 @@ export default function ContactPage({ params }: { params: { locale: string } })
<p>{dictionary.contact.availabilityDescription}</p>
</article>
<ContactForm
labels={{
name: dictionary.contact.formName,
email: dictionary.contact.formEmail,
message: dictionary.contact.formMessage,
submit: dictionary.contact.formSubmit,
sending: dictionary.contact.formSending,
success: dictionary.contact.formSuccess,
savedWarning: dictionary.contact.formSavedWarning,
invalid: dictionary.contact.formInvalid,
rateLimit: dictionary.contact.formRateLimit,
}}
/>
<div className="section-heading">
<h2>{dictionary.contact.channelsTitle}</h2>
</div>
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDetail from "@/components/public/project-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
import { getPublishedProject } from "@/lib/project-queries";
type DesignDetailPageProps = {
params: { locale: string; slug: string };
};
export function generateMetadata({ params }: DesignDetailPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "DESIGN");
return { title: copy.eyebrow, description: copy.description };
}
export default async function DesignDetailPage({ params }: DesignDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const project = await getPublishedProject("DESIGN", params.slug);
if (!project) notFound();
return <ProjectDetail locale={locale} type="DESIGN" project={project} />;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDirectory from "@/components/public/project-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
type DesignsPageProps = {
params: { locale: string };
searchParams: { category?: string };
};
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "DESIGN");
return { title: copy.title, description: copy.description };
}
export default function DesignsPage({ params, searchParams }: DesignsPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <ProjectDirectory locale={locale} type="DESIGN" categorySlug={searchParams.category} />;
}
+4 -2
View File
@@ -4,7 +4,8 @@ import BottomNav from "@/components/BottomNav";
import SiteFooter from "@/components/SiteFooter";
import SiteHeader from "@/components/SiteHeader";
import { getActiveLocale, getDictionary, getDirection, getEnabledLocales, isLocale, type Locale } from "@/lib/i18n";
import { isComingSoonMode } from "@/lib/site";
import { isSiteClosedMode } from "@/lib/site";
import AnalyticsTracker from "@/components/public/analytics-tracker";
export const dynamicParams = false;
@@ -23,7 +24,7 @@ export default function LocaleLayout({
notFound();
}
if (isComingSoonMode()) {
if (isSiteClosedMode()) {
redirect("/");
}
@@ -33,6 +34,7 @@ export default function LocaleLayout({
return (
<div dir={getDirection(locale)} className="site-shell">
<SiteHeader locale={locale} common={dictionary.common} />
<AnalyticsTracker event="PAGE_VIEW" />
<main className="page-content">{children}</main>
<BottomNav locale={locale} common={dictionary.common} />
<SiteFooter locale={locale} common={dictionary.common} />
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import LegalDocument from "@/components/public/legal-document";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getLegalDocument } from "@/lib/legal-content";
type LegalPageProps = { params: { locale: string } };
export function generateMetadata({ params }: LegalPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const document = getLegalDocument(getActiveLocale(params.locale as Locale), "impressum");
return { title: document.title, description: document.intro };
}
export default function ImpressumPage({ params }: LegalPageProps) {
if (!isLocale(params.locale)) notFound();
return <LegalDocument document={getLegalDocument(getActiveLocale(params.locale as Locale), "impressum")} />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import LegalDocument from "@/components/public/legal-document";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getLegalDocument } from "@/lib/legal-content";
type LegalPageProps = { params: { locale: string } };
export function generateMetadata({ params }: LegalPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const document = getLegalDocument(getActiveLocale(params.locale as Locale), "privacy");
return { title: document.title, description: document.intro };
}
export default function PrivacyPage({ params }: LegalPageProps) {
if (!isLocale(params.locale)) notFound();
return <LegalDocument document={getLegalDocument(getActiveLocale(params.locale as Locale), "privacy")} />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import LegalDocument from "@/components/public/legal-document";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getLegalDocument } from "@/lib/legal-content";
type LegalPageProps = { params: { locale: string } };
export function generateMetadata({ params }: LegalPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const document = getLegalDocument(getActiveLocale(params.locale as Locale), "terms");
return { title: document.title, description: document.intro };
}
export default function TermsPage({ params }: LegalPageProps) {
if (!isLocale(params.locale)) notFound();
return <LegalDocument document={getLegalDocument(getActiveLocale(params.locale as Locale), "terms")} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import MelodyDetail from "@/components/public/melody-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getPublishedMelody } from "@/lib/melody-queries";
type MelodyDetailPageProps = { params: { locale: string; slug: string } };
export async function generateMetadata({ params }: MelodyDetailPageProps): Promise<Metadata> {
if (!isLocale(params.locale)) return {};
const melody = await getPublishedMelody(params.slug);
if (!melody) return {};
return { title: getActiveLocale(params.locale as Locale) === "ar" ? melody.titleAr : melody.titleEn };
}
export default async function MelodyDetailPage({ params }: MelodyDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const melody = await getPublishedMelody(params.slug);
if (!melody) notFound();
return <MelodyDetail locale={locale} melody={melody} />;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import MelodyDirectory from "@/components/public/melody-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getMelodyDirectoryCopy } from "@/lib/melody-content";
type MelodiesPageProps = { params: { locale: string }; searchParams: { category?: string } };
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const copy = getMelodyDirectoryCopy(getActiveLocale(params.locale as Locale));
return { title: copy.title, description: copy.description };
}
export default function MelodiesPage({ params, searchParams }: MelodiesPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <MelodyDirectory locale={locale} categorySlug={searchParams.category} />;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDetail from "@/components/public/project-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
import { getPublishedProject } from "@/lib/project-queries";
type WebsiteDetailPageProps = {
params: { locale: string; slug: string };
};
export function generateMetadata({ params }: WebsiteDetailPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "WEBSITE");
return { title: copy.eyebrow, description: copy.description };
}
export default async function WebsiteDetailPage({ params }: WebsiteDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const project = await getPublishedProject("WEBSITE", params.slug);
if (!project) notFound();
return <ProjectDetail locale={locale} type="WEBSITE" project={project} />;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDirectory from "@/components/public/project-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
type WebsitesPageProps = {
params: { locale: string };
searchParams: { category?: string };
};
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "WEBSITE");
return { title: copy.title, description: copy.description };
}
export default function WebsitesPage({ params, searchParams }: WebsitesPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <ProjectDirectory locale={locale} type="WEBSITE" categorySlug={searchParams.category} />;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDetail from "@/components/public/project-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
import { getPublishedProject } from "@/lib/project-queries";
type WorkDetailPageProps = {
params: { locale: string; slug: string };
};
export function generateMetadata({ params }: WorkDetailPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "PORTFOLIO");
return { title: copy.eyebrow, description: copy.description };
}
export default async function WorkDetailPage({ params }: WorkDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const project = await getPublishedProject("PORTFOLIO", params.slug);
if (!project) notFound();
return <ProjectDetail locale={locale} type="PORTFOLIO" project={project} />;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDirectory from "@/components/public/project-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
type WorkPageProps = {
params: { locale: string };
searchParams: { category?: string };
};
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "PORTFOLIO");
return { title: copy.title, description: copy.description };
}
export default function WorkPage({ params, searchParams }: WorkPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <ProjectDirectory locale={locale} type="PORTFOLIO" categorySlug={searchParams.category} />;
}