+
{children}
diff --git a/app/[locale]/legal/impressum/page.tsx b/app/[locale]/legal/impressum/page.tsx
new file mode 100644
index 0000000..c48091f
--- /dev/null
+++ b/app/[locale]/legal/impressum/page.tsx
@@ -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
;
+}
diff --git a/app/[locale]/legal/privacy/page.tsx b/app/[locale]/legal/privacy/page.tsx
new file mode 100644
index 0000000..6813d2f
--- /dev/null
+++ b/app/[locale]/legal/privacy/page.tsx
@@ -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
;
+}
diff --git a/app/[locale]/legal/terms/page.tsx b/app/[locale]/legal/terms/page.tsx
new file mode 100644
index 0000000..7fca0a9
--- /dev/null
+++ b/app/[locale]/legal/terms/page.tsx
@@ -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
;
+}
diff --git a/app/[locale]/melodies/[slug]/page.tsx b/app/[locale]/melodies/[slug]/page.tsx
new file mode 100644
index 0000000..7d6e90d
--- /dev/null
+++ b/app/[locale]/melodies/[slug]/page.tsx
@@ -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
{
+ 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 ;
+}
diff --git a/app/[locale]/melodies/page.tsx b/app/[locale]/melodies/page.tsx
new file mode 100644
index 0000000..b2d7ecf
--- /dev/null
+++ b/app/[locale]/melodies/page.tsx
@@ -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 ;
+}
diff --git a/app/[locale]/websites/[slug]/page.tsx b/app/[locale]/websites/[slug]/page.tsx
new file mode 100644
index 0000000..0f1dabf
--- /dev/null
+++ b/app/[locale]/websites/[slug]/page.tsx
@@ -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 ;
+}
diff --git a/app/[locale]/websites/page.tsx b/app/[locale]/websites/page.tsx
new file mode 100644
index 0000000..b43044f
--- /dev/null
+++ b/app/[locale]/websites/page.tsx
@@ -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 ;
+}
diff --git a/app/[locale]/work/[slug]/page.tsx b/app/[locale]/work/[slug]/page.tsx
new file mode 100644
index 0000000..e60ff40
--- /dev/null
+++ b/app/[locale]/work/[slug]/page.tsx
@@ -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 ;
+}
diff --git a/app/[locale]/work/page.tsx b/app/[locale]/work/page.tsx
new file mode 100644
index 0000000..2a5aaea
--- /dev/null
+++ b/app/[locale]/work/page.tsx
@@ -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 ;
+}
diff --git a/app/admin/(protected)/analytics/page.tsx b/app/admin/(protected)/analytics/page.tsx
new file mode 100644
index 0000000..5dceba3
--- /dev/null
+++ b/app/admin/(protected)/analytics/page.tsx
@@ -0,0 +1,34 @@
+import AdminPageHeader from "@/components/admin/admin-page-header";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { getAnalyticsSummary } from "@/lib/analytics";
+
+const eventLabels: Record = {
+ PAGE_VIEW: "Page views",
+ PROJECT_OPEN: "Project opens",
+ MELODY_PLAY: "Melody plays",
+ PROJECT_LINK_CLICK: "Project link clicks",
+ CONTACT_SUBMITTED: "Contact submissions",
+};
+
+export default async function AnalyticsPage() {
+ const summary = await getAnalyticsSummary();
+ const grouped = summary.grouped.map((item) => ({ type: item.type, count: item._count._all }));
+
+ return (
+
+
+
+ {Object.entries(eventLabels).map(([type, label]) => (
+
{label} {grouped.find((item) => item.type === type)?.count ?? 0}
Last 30 days
+ ))}
+
+
+ Recent events
+
+ {summary.recent.length === 0 ? No events recorded yet.
: {summary.recent.map((event) =>
{eventLabels[event.type] ?? event.type} {event.path || "—"} · {event.createdAt.toLocaleString("en-GB")}
)}
}
+
+
+
Total events recorded: {summary.total}
+
+ );
+}
diff --git a/app/admin/(protected)/categories/[id]/edit/page.tsx b/app/admin/(protected)/categories/[id]/edit/page.tsx
new file mode 100644
index 0000000..909b20b
--- /dev/null
+++ b/app/admin/(protected)/categories/[id]/edit/page.tsx
@@ -0,0 +1,43 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import AdminPageHeader from "@/components/admin/admin-page-header";
+import CategoryForm from "@/components/admin/category-form";
+import { prisma } from "@/lib/db";
+
+type EditCategoryPageProps = {
+ params: { id: string };
+};
+
+export default async function EditCategoryPage({ params }: EditCategoryPageProps) {
+ const category = await prisma.category.findUnique({ where: { id: params.id } });
+
+ if (!category) {
+ notFound();
+ }
+
+ return (
+
+
+ Back to categories
+
+ }
+ />
+
+
+ );
+}
diff --git a/app/admin/(protected)/categories/actions.ts b/app/admin/(protected)/categories/actions.ts
new file mode 100644
index 0000000..ef645e2
--- /dev/null
+++ b/app/admin/(protected)/categories/actions.ts
@@ -0,0 +1,97 @@
+"use server";
+
+import { Prisma } from "@prisma/client";
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/db";
+import { categoryIdSchema, categorySchema } from "@/lib/validations/category";
+
+export type CategoryActionResult = {
+ success?: true;
+ error?: string;
+};
+
+async function isAdminAuthenticated() {
+ const session = await auth();
+ return Boolean(session?.user);
+}
+
+function getValidationError(error: unknown) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
+ return "A category with this slug already exists.";
+ }
+
+ return "Unable to save the category right now.";
+}
+
+export async function createCategory(input: unknown): Promise {
+ if (!(await isAdminAuthenticated())) {
+ return { error: "Your session has expired. Please sign in again." };
+ }
+
+ const parsed = categorySchema.safeParse(input);
+
+ if (!parsed.success) {
+ return { error: parsed.error.issues[0]?.message ?? "Invalid category data." };
+ }
+
+ try {
+ await prisma.category.create({ data: parsed.data });
+ revalidatePath("/admin/categories");
+ return { success: true };
+ } catch (error) {
+ return { error: getValidationError(error) };
+ }
+}
+
+export async function updateCategory(categoryId: string, input: unknown): Promise {
+ if (!(await isAdminAuthenticated())) {
+ return { error: "Your session has expired. Please sign in again." };
+ }
+
+ const validId = categoryIdSchema.safeParse(categoryId);
+ const parsed = categorySchema.safeParse(input);
+
+ if (!validId.success) {
+ return { error: "Invalid category id." };
+ }
+
+ if (!parsed.success) {
+ return { error: parsed.error.issues[0]?.message ?? "Invalid category data." };
+ }
+
+ try {
+ await prisma.category.update({ where: { id: validId.data }, data: parsed.data });
+ revalidatePath("/admin/categories");
+ revalidatePath(`/admin/categories/${validId.data}/edit`);
+ return { success: true };
+ } catch (error) {
+ return { error: getValidationError(error) };
+ }
+}
+
+export async function deleteCategory(formData: FormData) {
+ if (!(await isAdminAuthenticated())) {
+ redirect("/admin/login");
+ }
+
+ const validId = categoryIdSchema.safeParse(formData.get("categoryId"));
+
+ if (!validId.success) {
+ redirect("/admin/categories?error=invalid-id");
+ }
+
+ try {
+ await prisma.category.delete({ where: { id: validId.data } });
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
+ redirect("/admin/categories?error=category-in-use");
+ }
+
+ redirect("/admin/categories?error=delete-failed");
+ }
+
+ revalidatePath("/admin/categories");
+ redirect("/admin/categories");
+}
diff --git a/app/admin/(protected)/categories/new/page.tsx b/app/admin/(protected)/categories/new/page.tsx
new file mode 100644
index 0000000..565ed00
--- /dev/null
+++ b/app/admin/(protected)/categories/new/page.tsx
@@ -0,0 +1,24 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import AdminPageHeader from "@/components/admin/admin-page-header";
+import CategoryForm from "@/components/admin/category-form";
+
+export default function NewCategoryPage() {
+ return (
+
+
+ Back to categories
+
+ }
+ />
+
+
+ );
+}
diff --git a/app/admin/(protected)/categories/page.tsx b/app/admin/(protected)/categories/page.tsx
new file mode 100644
index 0000000..eb87fce
--- /dev/null
+++ b/app/admin/(protected)/categories/page.tsx
@@ -0,0 +1,101 @@
+import Link from "next/link";
+import { CategoryKind } from "@prisma/client";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import AdminPageHeader from "@/components/admin/admin-page-header";
+import { deleteCategory } from "@/app/admin/(protected)/categories/actions";
+import { prisma } from "@/lib/db";
+
+const kindLabels: Record = {
+ PROJECT: "Projects",
+ MELODY: "Melodies",
+};
+
+type CategoriesPageProps = {
+ searchParams: { error?: string };
+};
+
+function getErrorMessage(error?: string) {
+ if (error === "category-in-use") return "This category cannot be deleted while content is linked to it.";
+ if (error === "invalid-id") return "The selected category id is invalid.";
+ if (error === "delete-failed") return "The category could not be deleted.";
+ return null;
+}
+
+export default async function CategoriesPage({ searchParams }: CategoriesPageProps) {
+ const categories = await prisma.category.findMany({
+ orderBy: [{ order: "asc" }, { nameEn: "asc" }],
+ include: {
+ _count: {
+ select: { projects: true, melodies: true },
+ },
+ },
+ });
+ const errorMessage = getErrorMessage(searchParams.error);
+
+ return (
+
+
+ New category
+
+ }
+ />
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+ {categories.length === 0 ? (
+
+
+ No categories yet. Create the first one to organize content.
+
+
+ ) : (
+
+ {categories.map((category) => (
+
+
+
+
+ {kindLabels[category.kind]}
+
+
{category.nameEn}
+
+ {category.nameAr}
+
+
+
+ #{category.order}
+
+
+
+ /{category.slug}
+
+ {category._count.projects + category._count.melodies} linked items
+
+
+
+ Edit
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/admin/(protected)/layout.tsx b/app/admin/(protected)/layout.tsx
new file mode 100644
index 0000000..bbf55a6
--- /dev/null
+++ b/app/admin/(protected)/layout.tsx
@@ -0,0 +1,23 @@
+import type { ReactNode } from "react";
+import { redirect } from "next/navigation";
+import AdminShell from "@/components/admin/admin-shell";
+import { auth, signOut } from "@/lib/auth";
+
+export default async function AdminProtectedLayout({ children }: { children: ReactNode }) {
+ const session = await auth();
+
+ if (!session?.user) {
+ redirect("/admin/login");
+ }
+
+ async function handleSignOut() {
+ "use server";
+ await signOut({ redirectTo: "/admin/login" });
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/app/admin/(protected)/melodies/[id]/edit/page.tsx b/app/admin/(protected)/melodies/[id]/edit/page.tsx
new file mode 100644
index 0000000..46e9b57
--- /dev/null
+++ b/app/admin/(protected)/melodies/[id]/edit/page.tsx
@@ -0,0 +1,21 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import AdminPageHeader from "@/components/admin/admin-page-header";
+import MelodyForm from "@/components/admin/melody-form";
+import { prisma } from "@/lib/db";
+import type { MelodyInput } from "@/lib/validations/melody";
+
+export default async function EditMelodyPage({ params }: { params: { id: string } }) {
+ const [melody, categories] = await Promise.all([
+ prisma.melody.findUnique({ where: { id: params.id } }),
+ prisma.category.findMany({ where: { kind: "MELODY" }, orderBy: [{ order: "asc" }, { nameEn: "asc" }], select: { id: true, nameAr: true, nameEn: true } }),
+ ]);
+ if (!melody) notFound();
+ const defaultValues: MelodyInput = {
+ slug: melody.slug, titleAr: melody.titleAr, titleEn: melody.titleEn, descAr: melody.descAr ?? "", descEn: melody.descEn ?? "",
+ audioFile: melody.audioFile, coverImage: melody.coverImage ?? "", durationSec: melody.durationSec, isDownloadable: melody.isDownloadable,
+ status: melody.status, isFeatured: melody.isFeatured, sortOrder: melody.sortOrder, categoryId: melody.categoryId,
+ };
+ return ;
+}
diff --git a/app/admin/(protected)/melodies/actions.ts b/app/admin/(protected)/melodies/actions.ts
new file mode 100644
index 0000000..094e0be
--- /dev/null
+++ b/app/admin/(protected)/melodies/actions.ts
@@ -0,0 +1,116 @@
+"use server";
+
+import { Prisma } from "@prisma/client";
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/db";
+import { melodyIdSchema, melodySchema } from "@/lib/validations/melody";
+
+export type MelodyActionResult = {
+ success?: true;
+ error?: string;
+};
+
+async function isAdminAuthenticated() {
+ const session = await auth();
+ return Boolean(session?.user);
+}
+
+function getActionError(error: unknown) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
+ return "A melody with this slug already exists.";
+ }
+
+ return "Unable to save the melody right now.";
+}
+
+async function hasMelodyCategory(categoryId: string) {
+ const category = await prisma.category.findFirst({
+ where: { id: categoryId, kind: "MELODY" },
+ select: { id: true },
+ });
+ return Boolean(category);
+}
+
+function normalizeMelodyInput(input: unknown) {
+ const parsed = melodySchema.safeParse(input);
+ if (!parsed.success) {
+ return { error: parsed.error.issues[0]?.message ?? "Invalid melody data." } as const;
+ }
+
+ return {
+ data: {
+ ...parsed.data,
+ descAr: parsed.data.descAr || null,
+ descEn: parsed.data.descEn || null,
+ coverImage: parsed.data.coverImage || null,
+ },
+ } as const;
+}
+
+export async function createMelody(input: unknown): Promise {
+ if (!(await isAdminAuthenticated())) {
+ return { error: "Your session has expired. Please sign in again." };
+ }
+
+ const normalized = normalizeMelodyInput(input);
+ if ("error" in normalized) return normalized;
+ if (!(await hasMelodyCategory(normalized.data.categoryId))) {
+ return { error: "Choose a valid melody category." };
+ }
+
+ try {
+ await prisma.melody.create({ data: normalized.data });
+ revalidatePath("/admin/melodies");
+ revalidatePath("/en/melodies");
+ return { success: true };
+ } catch (error) {
+ return { error: getActionError(error) };
+ }
+}
+
+export async function updateMelody(melodyId: string, input: unknown): Promise {
+ if (!(await isAdminAuthenticated())) {
+ return { error: "Your session has expired. Please sign in again." };
+ }
+
+ const validId = melodyIdSchema.safeParse(melodyId);
+ if (!validId.success) return { error: "Invalid melody id." };
+
+ const normalized = normalizeMelodyInput(input);
+ if ("error" in normalized) return normalized;
+ if (!(await hasMelodyCategory(normalized.data.categoryId))) {
+ return { error: "Choose a valid melody category." };
+ }
+
+ try {
+ await prisma.melody.update({ where: { id: validId.data }, data: normalized.data });
+ revalidatePath("/admin/melodies");
+ revalidatePath(`/admin/melodies/${validId.data}/edit`);
+ revalidatePath("/en/melodies");
+ return { success: true };
+ } catch (error) {
+ return { error: getActionError(error) };
+ }
+}
+
+export async function deleteMelody(formData: FormData) {
+ if (!(await isAdminAuthenticated())) redirect("/admin/login");
+
+ const validId = melodyIdSchema.safeParse(formData.get("melodyId"));
+ if (!validId.success) redirect("/admin/melodies?error=invalid-id");
+
+ try {
+ await prisma.melody.delete({ where: { id: validId.data } });
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
+ redirect("/admin/melodies?error=not-found");
+ }
+ redirect("/admin/melodies?error=delete-failed");
+ }
+
+ revalidatePath("/admin/melodies");
+ revalidatePath("/en/melodies");
+ redirect("/admin/melodies");
+}
diff --git a/app/admin/(protected)/melodies/new/page.tsx b/app/admin/(protected)/melodies/new/page.tsx
new file mode 100644
index 0000000..7ff32cd
--- /dev/null
+++ b/app/admin/(protected)/melodies/new/page.tsx
@@ -0,0 +1,16 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import AdminPageHeader from "@/components/admin/admin-page-header";
+import MelodyForm from "@/components/admin/melody-form";
+import { prisma } from "@/lib/db";
+import type { MelodyInput } from "@/lib/validations/melody";
+
+export default async function NewMelodyPage() {
+ const categories = await prisma.category.findMany({ where: { kind: "MELODY" }, orderBy: [{ order: "asc" }, { nameEn: "asc" }], select: { id: true, nameAr: true, nameEn: true } });
+ const defaultValues: MelodyInput = {
+ slug: "", titleAr: "", titleEn: "", descAr: "", descEn: "", audioFile: "", coverImage: "", durationSec: null,
+ isDownloadable: false, status: "DRAFT", isFeatured: false, sortOrder: 0, categoryId: "",
+ };
+
+ return ;
+}
diff --git a/app/admin/(protected)/melodies/page.tsx b/app/admin/(protected)/melodies/page.tsx
new file mode 100644
index 0000000..5c43ae6
--- /dev/null
+++ b/app/admin/(protected)/melodies/page.tsx
@@ -0,0 +1,53 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import AdminPageHeader from "@/components/admin/admin-page-header";
+import { deleteMelody } from "@/app/admin/(protected)/melodies/actions";
+import { prisma } from "@/lib/db";
+
+type MelodiesPageProps = { searchParams: { error?: string } };
+
+function getErrorMessage(error?: string) {
+ if (error === "invalid-id") return "The selected melody id is invalid.";
+ if (error === "not-found") return "The selected melody no longer exists.";
+ if (error === "delete-failed") return "The melody could not be deleted.";
+ return null;
+}
+
+export default async function MelodiesPage({ searchParams }: MelodiesPageProps) {
+ const melodies = await prisma.melody.findMany({
+ orderBy: [{ isFeatured: "desc" }, { sortOrder: "asc" }, { updatedAt: "desc" }],
+ include: { category: { select: { nameEn: true, nameAr: true } } },
+ });
+ const errorMessage = getErrorMessage(searchParams.error);
+
+ return (
+
+
New melody} />
+ {errorMessage ? {errorMessage}
: null}
+ {melodies.length === 0 ? (
+ No melodies yet.
+ ) : (
+
+ {melodies.map((melody) => (
+
+
+ {melody.titleEn} {melody.titleAr}
+ {melody.status}
+
+
+ /{melody.slug}
+ {melody.category.nameEn} · {melody.category.nameAr}
+ {melody.durationSec ? `${melody.durationSec}s` : "No duration"} · {melody.isDownloadable ? "Download enabled" : "Streaming only"}
+
+ Edit
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/admin/(protected)/page.tsx b/app/admin/(protected)/page.tsx
new file mode 100644
index 0000000..c7ea8b6
--- /dev/null
+++ b/app/admin/(protected)/page.tsx
@@ -0,0 +1,48 @@
+import { redirect } from "next/navigation";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import AdminPageHeader from "@/components/admin/admin-page-header";
+import { auth } from "@/lib/auth";
+
+export default async function AdminPage() {
+ const session = await auth();
+
+ if (!session?.user) {
+ redirect("/admin/login");
+ }
+
+ return (
+
+
+
+
+
+
+ Signed-in account
+
+
+ {session.user.email}
+
+
+
+
+ Content status
+
+
+ Coming soon mode is active.
+
+
+
+
+ Next step
+
+
+ Categories and content tools are next.
+
+
+
+
+ );
+}
diff --git a/app/admin/(protected)/projects/[id]/edit/page.tsx b/app/admin/(protected)/projects/[id]/edit/page.tsx
new file mode 100644
index 0000000..128dc26
--- /dev/null
+++ b/app/admin/(protected)/projects/[id]/edit/page.tsx
@@ -0,0 +1,63 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import AdminPageHeader from "@/components/admin/admin-page-header";
+import ProjectForm from "@/components/admin/project-form";
+import { prisma } from "@/lib/db";
+import type { ProjectInput } from "@/lib/validations/project";
+
+export default async function EditProjectPage({ params }: { params: { id: string } }) {
+ const [project, categories] = await Promise.all([
+ prisma.project.findUnique({ where: { id: params.id } }),
+ prisma.category.findMany({
+ where: { kind: "PROJECT" },
+ orderBy: [{ order: "asc" }, { nameEn: "asc" }],
+ select: { id: true, nameAr: true, nameEn: true },
+ }),
+ ]);
+
+ if (!project) {
+ notFound();
+ }
+
+ const defaultValues: ProjectInput = {
+ type: project.type,
+ slug: project.slug,
+ titleAr: project.titleAr,
+ titleEn: project.titleEn,
+ summaryAr: project.summaryAr ?? "",
+ summaryEn: project.summaryEn ?? "",
+ descAr: project.descAr ?? "",
+ descEn: project.descEn ?? "",
+ coverImage: project.coverImage ?? "",
+ images: project.images,
+ technologies: project.technologies,
+ externalUrl: project.externalUrl ?? "",
+ repoUrl: project.repoUrl ?? "",
+ platform: project.platform ?? "",
+ appStoreUrl: project.appStoreUrl ?? "",
+ testflightUrl: project.testflightUrl ?? "",
+ appVersion: project.appVersion ?? "",
+ supportUrl: project.supportUrl ?? "",
+ appPrivacyUrl: project.appPrivacyUrl ?? "",
+ status: project.status,
+ isFeatured: project.isFeatured,
+ sortOrder: project.sortOrder,
+ categoryId: project.categoryId ?? "",
+ };
+
+ return (
+
+
+ Back to projects
+
+ }
+ />
+
+
+ );
+}
diff --git a/app/admin/(protected)/projects/actions.ts b/app/admin/(protected)/projects/actions.ts
new file mode 100644
index 0000000..4117adb
--- /dev/null
+++ b/app/admin/(protected)/projects/actions.ts
@@ -0,0 +1,150 @@
+"use server";
+
+import { Prisma } from "@prisma/client";
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/db";
+import { projectIdSchema, projectSchema } from "@/lib/validations/project";
+
+export type ProjectActionResult = {
+ success?: true;
+ error?: string;
+};
+
+async function isAdminAuthenticated() {
+ const session = await auth();
+ return Boolean(session?.user);
+}
+
+function getActionError(error: unknown) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
+ return "A project with this slug already exists.";
+ }
+
+ return "Unable to save the project right now.";
+}
+
+async function validateProjectCategory(categoryId: string | undefined) {
+ if (!categoryId) {
+ return null;
+ }
+
+ const category = await prisma.category.findFirst({
+ where: { id: categoryId, kind: "PROJECT" },
+ select: { id: true },
+ });
+
+ return category?.id ?? false;
+}
+
+function normalizeProjectInput(input: unknown) {
+ const parsed = projectSchema.safeParse(input);
+ if (!parsed.success) {
+ return { error: parsed.error.issues[0]?.message ?? "Invalid project data." } as const;
+ }
+
+ return {
+ data: {
+ ...parsed.data,
+ categoryId: parsed.data.categoryId || null,
+ coverImage: parsed.data.coverImage || null,
+ summaryAr: parsed.data.summaryAr || null,
+ summaryEn: parsed.data.summaryEn || null,
+ descAr: parsed.data.descAr || null,
+ descEn: parsed.data.descEn || null,
+ externalUrl: parsed.data.externalUrl || null,
+ repoUrl: parsed.data.repoUrl || null,
+ platform: parsed.data.platform || null,
+ appStoreUrl: parsed.data.appStoreUrl || null,
+ testflightUrl: parsed.data.testflightUrl || null,
+ appVersion: parsed.data.appVersion || null,
+ supportUrl: parsed.data.supportUrl || null,
+ appPrivacyUrl: parsed.data.appPrivacyUrl || null,
+ },
+ } as const;
+}
+
+export async function createProject(input: unknown): Promise {
+ if (!(await isAdminAuthenticated())) {
+ return { error: "Your session has expired. Please sign in again." };
+ }
+
+ const normalized = normalizeProjectInput(input);
+ if ("error" in normalized) {
+ return normalized;
+ }
+
+ if ((await validateProjectCategory(normalized.data.categoryId ?? undefined)) === false) {
+ return { error: "Choose a valid project category." };
+ }
+
+ try {
+ await prisma.project.create({ data: normalized.data });
+ revalidatePath("/admin/projects");
+ revalidatePath("/en/work");
+ revalidatePath("/en/apps");
+ revalidatePath("/en/websites");
+ revalidatePath("/en/designs");
+ return { success: true };
+ } catch (error) {
+ return { error: getActionError(error) };
+ }
+}
+
+export async function updateProject(projectId: string, input: unknown): Promise {
+ if (!(await isAdminAuthenticated())) {
+ return { error: "Your session has expired. Please sign in again." };
+ }
+
+ const validId = projectIdSchema.safeParse(projectId);
+ if (!validId.success) {
+ return { error: "Invalid project id." };
+ }
+
+ const normalized = normalizeProjectInput(input);
+ if ("error" in normalized) {
+ return normalized;
+ }
+
+ if ((await validateProjectCategory(normalized.data.categoryId ?? undefined)) === false) {
+ return { error: "Choose a valid project category." };
+ }
+
+ try {
+ await prisma.project.update({ where: { id: validId.data }, data: normalized.data });
+ revalidatePath("/admin/projects");
+ revalidatePath(`/admin/projects/${validId.data}/edit`);
+ revalidatePath("/en/work");
+ revalidatePath("/en/apps");
+ revalidatePath("/en/websites");
+ revalidatePath("/en/designs");
+ return { success: true };
+ } catch (error) {
+ return { error: getActionError(error) };
+ }
+}
+
+export async function deleteProject(formData: FormData) {
+ if (!(await isAdminAuthenticated())) {
+ redirect("/admin/login");
+ }
+
+ const validId = projectIdSchema.safeParse(formData.get("projectId"));
+ if (!validId.success) {
+ redirect("/admin/projects?error=invalid-id");
+ }
+
+ try {
+ await prisma.project.delete({ where: { id: validId.data } });
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
+ redirect("/admin/projects?error=not-found");
+ }
+
+ redirect("/admin/projects?error=delete-failed");
+ }
+
+ revalidatePath("/admin/projects");
+ redirect("/admin/projects");
+}
diff --git a/app/admin/(protected)/projects/new/page.tsx b/app/admin/(protected)/projects/new/page.tsx
new file mode 100644
index 0000000..c6ad6e9
--- /dev/null
+++ b/app/admin/(protected)/projects/new/page.tsx
@@ -0,0 +1,55 @@
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import AdminPageHeader from "@/components/admin/admin-page-header";
+import ProjectForm from "@/components/admin/project-form";
+import { prisma } from "@/lib/db";
+import type { ProjectInput } from "@/lib/validations/project";
+
+export default async function NewProjectPage() {
+ const categories = await prisma.category.findMany({
+ where: { kind: "PROJECT" },
+ orderBy: [{ order: "asc" }, { nameEn: "asc" }],
+ select: { id: true, nameAr: true, nameEn: true },
+ });
+
+ const defaultValues: ProjectInput = {
+ type: "PORTFOLIO",
+ slug: "",
+ titleAr: "",
+ titleEn: "",
+ summaryAr: "",
+ summaryEn: "",
+ descAr: "",
+ descEn: "",
+ coverImage: "",
+ images: [],
+ technologies: [],
+ externalUrl: "",
+ repoUrl: "",
+ platform: "",
+ appStoreUrl: "",
+ testflightUrl: "",
+ appVersion: "",
+ supportUrl: "",
+ appPrivacyUrl: "",
+ status: "DRAFT",
+ isFeatured: false,
+ sortOrder: 0,
+ categoryId: "",
+ };
+
+ return (
+
+
+ Back to projects
+
+ }
+ />
+
+
+ );
+}
diff --git a/app/admin/(protected)/projects/page.tsx b/app/admin/(protected)/projects/page.tsx
new file mode 100644
index 0000000..406002e
--- /dev/null
+++ b/app/admin/(protected)/projects/page.tsx
@@ -0,0 +1,113 @@
+import Link from "next/link";
+import { ProjectType } from "@prisma/client";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import AdminPageHeader from "@/components/admin/admin-page-header";
+import { deleteProject } from "@/app/admin/(protected)/projects/actions";
+import { prisma } from "@/lib/db";
+import { projectTypeFilterSchema } from "@/lib/validations/project";
+
+const typeLabels: Record = {
+ PORTFOLIO: "Portfolio",
+ APP: "App",
+ WEBSITE: "Website",
+ DESIGN: "Design",
+};
+
+type ProjectsPageProps = {
+ searchParams: { type?: string; error?: string };
+};
+
+function getErrorMessage(error?: string) {
+ if (error === "invalid-id") return "The selected project id is invalid.";
+ if (error === "not-found") return "The selected project no longer exists.";
+ if (error === "delete-failed") return "The project could not be deleted.";
+ return null;
+}
+
+export default async function ProjectsPage({ searchParams }: ProjectsPageProps) {
+ const type = projectTypeFilterSchema.safeParse(searchParams.type);
+ const selectedType = type.success ? type.data : undefined;
+ const projects = await prisma.project.findMany({
+ where: selectedType ? { type: selectedType } : undefined,
+ orderBy: [{ sortOrder: "asc" }, { updatedAt: "desc" }],
+ include: { category: { select: { nameEn: true, nameAr: true } } },
+ });
+ const errorMessage = getErrorMessage(searchParams.error);
+
+ return (
+
+
+ New project
+
+ }
+ />
+
+ {errorMessage ? (
+
+ {errorMessage}
+
+ ) : null}
+
+
+
+ All
+
+ {Object.entries(typeLabels).map(([value, label]) => (
+
+ {label}
+
+ ))}
+
+
+ {projects.length === 0 ? (
+
+
+ No projects match this filter yet.
+
+
+ ) : (
+
+ {projects.map((project) => (
+
+
+
+
{typeLabels[project.type]}
+
{project.titleEn}
+
+ {project.titleAr}
+
+
+ {project.status}
+
+
+ /{project.slug}
+
+ {project.category ? `${project.category.nameEn} · ${project.category.nameAr}` : "No category"}
+
+
+ {project.images.length} images · {project.technologies.length} technologies
+
+
+
+ Edit
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx
new file mode 100644
index 0000000..86c3764
--- /dev/null
+++ b/app/admin/login/page.tsx
@@ -0,0 +1,17 @@
+import { redirect } from "next/navigation";
+import LoginForm from "@/components/admin/login-form";
+import { auth } from "@/lib/auth";
+
+export default async function AdminLoginPage() {
+ const session = await auth();
+
+ if (session?.user) {
+ redirect("/admin");
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/app/api/analytics/route.ts b/app/api/analytics/route.ts
new file mode 100644
index 0000000..0ae8a83
--- /dev/null
+++ b/app/api/analytics/route.ts
@@ -0,0 +1,19 @@
+import { NextResponse } from "next/server";
+import { headers } from "next/headers";
+import { recordAnalyticsEvent } from "@/lib/analytics";
+import { consumeRateLimit, getClientIp } from "@/lib/rate-limit";
+
+export async function POST(request: Request) {
+ const ip = getClientIp(headers());
+ if (!consumeRateLimit(`analytics:${ip}`, 60, 60 * 1000)) {
+ return NextResponse.json({ error: "rate-limited" }, { status: 429 });
+ }
+
+ try {
+ const input = await request.json();
+ const recorded = await recordAnalyticsEvent(input);
+ return recorded ? new NextResponse(null, { status: 204 }) : NextResponse.json({ error: "invalid-event" }, { status: 400 });
+ } catch {
+ return NextResponse.json({ error: "invalid-request" }, { status: 400 });
+ }
+}
diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..c55a45e
--- /dev/null
+++ b/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,3 @@
+import { handlers } from "@/lib/auth";
+
+export const { GET, POST } = handlers;
diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts
new file mode 100644
index 0000000..ab80131
--- /dev/null
+++ b/app/api/upload/route.ts
@@ -0,0 +1,33 @@
+import { NextResponse } from "next/server";
+import { auth } from "@/lib/auth";
+import { saveUploadedFile, UploadError } from "@/lib/upload";
+import { uploadKindSchema } from "@/lib/validations/upload";
+
+export const runtime = "nodejs";
+
+export async function POST(request: Request) {
+ const session = await auth();
+ if (!session?.user) {
+ return NextResponse.json({ error: "Unauthorized." }, { status: 401 });
+ }
+
+ try {
+ const formData = await request.formData();
+ const kindResult = uploadKindSchema.safeParse(formData.get("kind"));
+ const file = formData.get("file");
+
+ if (!kindResult.success || !(file instanceof File)) {
+ return NextResponse.json({ error: "A valid upload kind and file are required." }, { status: 400 });
+ }
+
+ const uploadedFile = await saveUploadedFile(file, kindResult.data);
+ return NextResponse.json(uploadedFile, { status: 201 });
+ } catch (error) {
+ if (error instanceof UploadError) {
+ return NextResponse.json({ error: error.message }, { status: error.statusCode });
+ }
+
+ console.error("Upload failed", error);
+ return NextResponse.json({ error: "The file could not be uploaded." }, { status: 500 });
+ }
+}
diff --git a/app/globals.css b/app/globals.css
index 95f82a1..e5f7372 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,3 +1,7 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
:root {
--bg: #07111f;
--surface: rgba(11, 20, 36, 0.84);
diff --git a/app/layout.tsx b/app/layout.tsx
index ff911e7..e3075df 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,11 +1,10 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
-import { siteConfig } from "@/lib/site";
-import { isComingSoonMode } from "@/lib/site";
+import { isSiteClosedMode, siteConfig } from "@/lib/site";
import "./globals.css";
export function generateMetadata(): Metadata {
- const comingSoon = isComingSoonMode();
+ const siteClosed = isSiteClosedMode();
return {
metadataBase: new URL(siteConfig.siteUrl),
@@ -13,7 +12,7 @@ export function generateMetadata(): Metadata {
default: "Diyaa",
template: "%s | Diyaa",
},
- description: comingSoon
+ description: siteClosed
? "Minimal coming soon page for the upcoming launch."
: "Bilingual professional website built for private-server deployment.",
applicationName: "Diyaa",
@@ -21,7 +20,7 @@ export function generateMetadata(): Metadata {
creator: "Diyaa",
publisher: "Diyaa",
alternates: {
- languages: comingSoon
+ languages: siteClosed
? {
en: "/",
"x-default": "/",
@@ -36,11 +35,11 @@ export function generateMetadata(): Metadata {
}
function getThemeScript() {
- const comingSoon = isComingSoonMode();
+ const siteClosed = isSiteClosedMode();
return `
(() => {
- const locale = ${comingSoon ? '"en"' : 'window.location.pathname.split("/").filter(Boolean)[0] === "en" ? "en" : "ar"'};
+ const locale = ${siteClosed ? '"en"' : 'window.location.pathname.split("/").filter(Boolean)[0] === "en" ? "en" : "ar"'};
const direction = locale === "ar" ? "rtl" : "ltr";
document.documentElement.lang = locale;
document.documentElement.dir = direction;
@@ -56,10 +55,10 @@ function getThemeScript() {
}
export default function RootLayout({ children }: { children: ReactNode }) {
- const comingSoon = isComingSoonMode();
+ const siteClosed = isSiteClosedMode();
return (
-
+
diff --git a/app/not-found.tsx b/app/not-found.tsx
index cd379d4..2aa988a 100644
--- a/app/not-found.tsx
+++ b/app/not-found.tsx
@@ -1,8 +1,8 @@
import Link from "next/link";
-import { isComingSoonMode } from "@/lib/site";
+import { isSiteClosedMode } from "@/lib/site";
export default function NotFound() {
- const comingSoon = isComingSoonMode();
+ const siteClosed = isSiteClosedMode();
return (
@@ -11,14 +11,14 @@ export default function NotFound() {
404
الصفحة غير موجودة
- {comingSoon
+ {siteClosed
? "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى الصفحة الرئيسية."
: "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى النسخة العربية أو الإنجليزية من الصفحة الرئيسية."}
- {comingSoon ? (
+ {siteClosed ? (
العودة إلى الرئيسية
diff --git a/app/page.tsx b/app/page.tsx
index 260e75d..10ea84f 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,8 +1,8 @@
import { redirect } from "next/navigation";
-import { isComingSoonMode } from "@/lib/site";
+import { isSiteClosedMode } from "@/lib/site";
export default function RootPage() {
- if (isComingSoonMode()) {
+ if (isSiteClosedMode()) {
return (
Coming Soon123
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 2d5811b..7ee6a45 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -1,16 +1,15 @@
import type { MetadataRoute } from "next";
-import { getLocalizedPath, siteConfig } from "@/lib/site";
-import { isComingSoonMode } from "@/lib/site";
+import { getLocalizedPath, isSiteClosedMode, siteConfig } from "@/lib/site";
import type { Locale } from "@/lib/i18n";
-const allPages = ["", "/about", "/contact"] as const;
+const allPages = ["", "/about", "/contact", "/legal/privacy", "/legal/terms", "/legal/impressum"] as const;
const allLocales = ["ar", "en"] as const;
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
- const comingSoon = isComingSoonMode();
- const pages: readonly (typeof allPages)[number][] = comingSoon ? [] : allPages;
- const locales: readonly Locale[] = comingSoon ? [] : allLocales;
+ const siteClosed = isSiteClosedMode();
+ const pages: readonly (typeof allPages)[number][] = siteClosed ? [] : allPages;
+ const locales: readonly Locale[] = siteClosed ? [] : allLocales;
return [
{
diff --git a/components.json b/components.json
new file mode 100644
index 0000000..196231c
--- /dev/null
+++ b/components.json
@@ -0,0 +1,20 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "tailwind.config.js",
+ "css": "app/globals.css",
+ "baseColor": "slate",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ }
+}
diff --git a/components/SiteFooter.tsx b/components/SiteFooter.tsx
index a956759..b0e36ea 100644
--- a/components/SiteFooter.tsx
+++ b/components/SiteFooter.tsx
@@ -1,3 +1,4 @@
+import Link from "next/link";
import type { Locale } from "@/lib/i18n";
import type { CommonContent } from "@/content/types";
import { getModeValue, isComingSoonMode } from "@/lib/site";
@@ -19,7 +20,16 @@ export default function SiteFooter({ locale, common }: SiteFooterProps) {
{common.footerRights.replace("{year}", String(year))}
{commonVariant.footerBuiltWith}
- {!isComingSoon ?