diff --git a/.env.example b/.env.example index ec7b265..7feb284 100644 --- a/.env.example +++ b/.env.example @@ -8,10 +8,43 @@ NEXT_TELEMETRY_DISABLED=1 # ─── Site ────────────────────────────────────────────────── NEXT_PUBLIC_SITE_URL=https://diyaa.de -# coming-soon | live +# coming-soon | maintenance | full NEXT_PUBLIC_SITE_MODE=coming-soon # ─── Contact & Social ────────────────────────────────────── NEXT_PUBLIC_CONTACT_EMAIL=work@diyaa.de NEXT_PUBLIC_LINKEDIN_URL=https://linkedin.com/in/YOUR_USERNAME NEXT_PUBLIC_GITHUB_URL=https://github.com/YOUR_USERNAME + +# ─── Database (PostgreSQL + Prisma) ─────────────────────── +# Development-only defaults. Keep real credentials in .env, never in git. +POSTGRES_DB=diyaa +POSTGRES_USER=diyaa +POSTGRES_PASSWORD=change-me +POSTGRES_PORT=55432 +DATABASE_URL=postgresql://diyaa:change-me@localhost:55432/diyaa?schema=public +UPLOAD_DIR=/app/uploads + +# ─── Authentication ─────────────────────────────────────── +AUTH_SECRET=replace-with-a-long-random-secret +AUTH_URL=http://localhost:3000 +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=replace-with-a-long-admin-password + +# ─── SMTP Email ──────────────────────────────────────────── +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER=replace-with-smtp-user +SMTP_PASSWORD=replace-with-smtp-password +SMTP_FROM=website@example.com +SMTP_TO=work@diyaa.de + +# ─── Legal identity ─────────────────────────────────────── +LEGAL_NAME=Diyaa +LEGAL_ADDRESS=Street and house number +LEGAL_POSTAL_CODE=00000 +LEGAL_CITY=City +LEGAL_COUNTRY=Germany +# Optional when applicable. +LEGAL_VAT_ID= diff --git a/.gitignore b/.gitignore index 196c89e..af13e35 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ .env.local .env.production.local .next +uploads node_modules npm-debug.log* +*.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..da4e0b6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# 🤖 AGENTS.md — دليل الوكلاء لمشروع diyaa.de + +> أي agent (Claude Code أو غيره) يشتغل على هالمشروع لازم يتبع هالملف. الهدف: تعرف الوضع وتشتغل صح **بدون ما تقرأ كل الكود**. + +--- + +## 1) قبل أي شغل — اقرأ التوثيق + +كل الحقيقة موجودة بمجلد [`docs/`](./docs/): + +1. [`docs/PROJECT-STATE.md`](./docs/PROJECT-STATE.md) — **الوضع الحالي الفعلي**. اقرأه أول شي. +2. [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) — المعمارية، مخطط الداتابيس، اتفاقيات المجلدات والمكوّنات. +3. [`docs/STACK.md`](./docs/STACK.md) — الستاك المعتمد (لا تضيف مكتبة خارج المعتمد بدون توثيق السبب). +4. [`docs/TODO.md`](./docs/TODO.md) — المهام المفتوحة. +5. [`docs/CHECKLIST.md`](./docs/CHECKLIST.md) — نظرة شاملة على المتبقّي. +6. [`docs/IDEAS.md`](./docs/IDEAS.md) — أفكار مستقبلية. + +## 2) لما يُسأل "شو عنا شي نعملو؟" + +- افتح `docs/TODO.md`. +- اقترح **أول مهمة `🔵 مفتوحة` وغير محجوبة** من أعلى القائمة. +- لا تقترح مهمة حالتها `⛔ محجوبة` قبل ما تخلص المهمة يلي بتحجبها. + +## 3) أثناء التنفيذ + +- غيّر حالة المهمة بـ `TODO.md` لـ `🟡 قيد التنفيذ`. +- التزم باتفاقيات `ARCHITECTURE.md`: + - **كل شي مكوّنات قابلة لإعادة الاستخدام.** + - **Tailwind + shadcn/ui** فقط للتنسيق (بدون CSS خام جديد). + - **كل المكتبات محليّة** — بدون CDN وقت التشغيل. + - Server Components افتراضياً؛ `"use client"` عند الحاجة فقط. + - دعم ar/en و RTL/LTR بكل مكوّن عام. + - كل مدخلات المستخدم تتحقّق بـ Zod. + +## 4) بعد إنجاز المهمة (إلزامي) + +1. **`docs/TODO.md`**: انقل المهمة من "🔵 مفتوحة" لـ "✅ منجزة (Ready)" مع التاريخ ورقم المهمة، وحرّر حالات المهام يلي انفتحت (من ⛔ لـ 🔵). +2. **`docs/PROJECT-STATE.md`**: حدّث جداول "الموجود/الناقص"، وأضف سطر بالـ Changelog إذا كانت خطوة كبيرة. +3. **`docs/CHECKLIST.md`**: علّم البنود المنجزة `[x]`. +4. إذا انبثقت فكرة جديدة مو مجدولة → ضيفها لـ `docs/IDEAS.md`. + +> ⚠️ **القاعدة الذهبية:** لا تخلّي الكود يسبق التوثيق. إذا غيّرت كود وما وثّقت — الشغل مو خالص. + +## 5) أوامر مفيدة + +```bash +make start # تشغيل عبر Docker +make logs # اللوقات +make stop +npm run dev # تطوير محلي +npm run check # lint + typecheck + build +``` + +(بعد إضافة الداتابيس رح ينضاف: `make migrate`, `make studio`, `make seed` — شوف `ARCHITECTURE.md §9`.) + +## 6) حدود مهمة + +- **لا ترفع أسرار للـ git** (SMTP, DATABASE_URL, AUTH secret). كلها بـ `.env` فقط. +- لا تضيف dependency خارج `STACK.md` بدون توثيق السبب بـ `PROJECT-STATE.md`. +- لا تكسر وضع coming-soon / الصيانة الموجود. +- حافظ على بنية النشر (Docker + Traefik) شغّالة. diff --git a/Dockerfile b/Dockerfile index b33d241..b970f8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,7 @@ ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL ENV NEXT_PUBLIC_LINKEDIN_URL=$NEXT_PUBLIC_LINKEDIN_URL ENV NEXT_PUBLIC_GITHUB_URL=$NEXT_PUBLIC_GITHUB_URL RUN addgroup -S nextjs && adduser -S nextjs -G nextjs +RUN mkdir -p /app/uploads && chown nextjs:nextjs /app/uploads COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ diff --git a/app/[locale]/about/page.tsx b/app/[locale]/about/page.tsx index 516c43c..34d7497 100644 --- a/app/[locale]/about/page.tsx +++ b/app/[locale]/about/page.tsx @@ -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("/"); } diff --git a/app/[locale]/apps/[slug]/page.tsx b/app/[locale]/apps/[slug]/page.tsx new file mode 100644 index 0000000..cf9dbe9 --- /dev/null +++ b/app/[locale]/apps/[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 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 ; +} diff --git a/app/[locale]/apps/page.tsx b/app/[locale]/apps/page.tsx new file mode 100644 index 0000000..f64639d --- /dev/null +++ b/app/[locale]/apps/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 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 ; +} diff --git a/app/[locale]/contact/actions.ts b/app/[locale]/contact/actions.ts new file mode 100644 index 0000000..35707b4 --- /dev/null +++ b/app/[locale]/contact/actions.ts @@ -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 { + 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" }; + } +} diff --git a/app/[locale]/contact/page.tsx b/app/[locale]/contact/page.tsx index 3306f36..882212d 100644 --- a/app/[locale]/contact/page.tsx +++ b/app/[locale]/contact/page.tsx @@ -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 } })

{dictionary.contact.availabilityDescription}

+ +

{dictionary.contact.channelsTitle}

diff --git a/app/[locale]/designs/[slug]/page.tsx b/app/[locale]/designs/[slug]/page.tsx new file mode 100644 index 0000000..c2521de --- /dev/null +++ b/app/[locale]/designs/[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 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 ; +} diff --git a/app/[locale]/designs/page.tsx b/app/[locale]/designs/page.tsx new file mode 100644 index 0000000..a8d3a7e --- /dev/null +++ b/app/[locale]/designs/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 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 ; +} diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 434850f..9a711b7 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -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 (
+
{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 +

+
+ +
+ + +
+
+
+
+ ))} +
+ )} +
+ ); +} 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
Back to melodies} />
; +} 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
Back to melodies} />
; +} 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"}

+
+ +
+
+
+
+ ))} +
+ )} +
+ ); +} 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} + +
+ + {Object.entries(typeLabels).map(([value, 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 +

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