Compare commits

...
18 Commits
Author SHA1 Message Date
diyaa c26f41511f feat: full site build — Project/Melody schema (Option A), admin CRUD, public sections, uploads, email+SMTP, internal analytics, legal pages, docs 2026-08-05 20:53:40 +02:00
diyaa d87f3033c6 Update test 2026-08-02 22:04:52 +02:00
diyaa 8c3849f023 Update Docker compose for traefik connect 2026-08-02 10:48:43 +02:00
diyaa 2ef51e0cb9 add logo icon 2026-03-22 23:08:30 +01:00
diyaa e135a0243a Delete icon-png 2026-03-19 00:07:47 +01:00
diyaa 30fe3e8cd0 update icon web 2026-03-19 00:00:24 +01:00
diyaa 44b5ab22ae Trash defualt.conf 2026-03-16 08:08:39 +01:00
diyaa 349412ea03 edit docker-compose.yml 2026-03-16 07:53:04 +01:00
diyaa 743143af39 fixed default.conf file 2026-03-15 22:23:53 +01:00
diyaa 616e372705 fix default.conf edit 2026-03-15 22:10:28 +01:00
diyaa cf5cde7a1c fix defualt-conf 2026-03-15 22:04:57 +01:00
diyaa d0425d4683 update default.conf 2026-03-15 21:59:34 +01:00
diyaa 03eca6c59c edit conf file 2026-03-15 21:50:35 +01:00
diyaa 100b9e0e3e add https for web 2026-03-15 21:45:00 +01:00
diyaa a43c47250b fix nginx 2026-03-15 21:36:44 +01:00
diyaa e51c786782 fix nginx 2026-03-15 21:32:32 +01:00
diyaa 55013326a3 add .conf for nginx 2026-03-15 20:42:02 +01:00
diyaa 838e535650 fix docker port 2026-03-15 20:22:28 +01:00
125 changed files with 15106 additions and 51 deletions
+45 -6
View File
@@ -1,11 +1,50 @@
# ─── Project ───────────────────────────────────────────────
COMPOSE_PROJECT_NAME=diyaa COMPOSE_PROJECT_NAME=diyaa
CONTAINER_NAME=diyaa CONTAINER_NAME=diyaa-website
APP_PORT=30002
# ─── App ───────────────────────────────────────────────────
NODE_ENV=production NODE_ENV=production
NEXT_TELEMETRY_DISABLED=1 NEXT_TELEMETRY_DISABLED=1
# ─── Site ──────────────────────────────────────────────────
NEXT_PUBLIC_SITE_URL=https://diyaa.de
# coming-soon | maintenance | full
NEXT_PUBLIC_SITE_MODE=coming-soon NEXT_PUBLIC_SITE_MODE=coming-soon
NEXT_PUBLIC_SITE_URL=https://your-domain.example # ─── Contact & Social ──────────────────────────────────────
NEXT_PUBLIC_CONTACT_EMAIL= NEXT_PUBLIC_CONTACT_EMAIL=work@diyaa.de
NEXT_PUBLIC_LINKEDIN_URL= NEXT_PUBLIC_LINKEDIN_URL=https://linkedin.com/in/YOUR_USERNAME
NEXT_PUBLIC_GITHUB_URL= 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=
+2
View File
@@ -3,5 +3,7 @@
.env.local .env.local
.env.production.local .env.production.local
.next .next
uploads
node_modules node_modules
npm-debug.log* npm-debug.log*
*.tsbuildinfo
+61
View File
@@ -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) شغّالة.
+1
View File
@@ -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_LINKEDIN_URL=$NEXT_PUBLIC_LINKEDIN_URL
ENV NEXT_PUBLIC_GITHUB_URL=$NEXT_PUBLIC_GITHUB_URL ENV NEXT_PUBLIC_GITHUB_URL=$NEXT_PUBLIC_GITHUB_URL
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs 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/public ./public
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
+2 -2
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getActiveLocale, getDictionary, isLocale, type Locale } from "@/lib/i18n"; import { getActiveLocale, getDictionary, isLocale, type Locale } from "@/lib/i18n";
import { buildPageMetadata } from "@/lib/metadata"; import { buildPageMetadata } from "@/lib/metadata";
import { isComingSoonMode } from "@/lib/site"; import { isSiteClosedMode } from "@/lib/site";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
export function generateMetadata({ params }: { params: { locale: string } }): Metadata { export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
@@ -17,7 +17,7 @@ export default function AboutPage({ params }: { params: { locale: string } }) {
notFound(); notFound();
} }
if (isComingSoonMode()) { if (isSiteClosedMode()) {
redirect("/"); redirect("/");
} }
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDetail from "@/components/public/project-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
import { getPublishedProject } from "@/lib/project-queries";
type AppDetailPageProps = {
params: { locale: string; slug: string };
};
export function generateMetadata({ params }: AppDetailPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "APP");
return { title: copy.eyebrow, description: copy.description };
}
export default async function AppDetailPage({ params }: AppDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const project = await getPublishedProject("APP", params.slug);
if (!project) notFound();
return <ProjectDetail locale={locale} type="APP" project={project} />;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDirectory from "@/components/public/project-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
type AppsPageProps = {
params: { locale: string };
searchParams: { category?: string };
};
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "APP");
return { title: copy.title, description: copy.description };
}
export default function AppsPage({ params, searchParams }: AppsPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <ProjectDirectory locale={locale} type="APP" categorySlug={searchParams.category} />;
}
+39
View File
@@ -0,0 +1,39 @@
"use server";
import { headers } from "next/headers";
import { prisma } from "@/lib/db";
import { recordAnalyticsEvent } from "@/lib/analytics";
import { sendContactMessageEmail } from "@/lib/email";
import { consumeContactRateLimit, getClientIp } from "@/lib/rate-limit";
import { contactSchema } from "@/lib/validations/contact";
export type ContactActionResult = {
success?: true;
warning?: "email-not-sent";
error?: "invalid" | "rate-limit";
};
export async function submitContactMessage(input: unknown): Promise<ContactActionResult> {
const parsed = contactSchema.safeParse(input);
if (!parsed.success) return { error: "invalid" };
if (parsed.data.website) return { success: true };
const ip = getClientIp(headers());
if (!consumeContactRateLimit(`contact:${ip}`)) return { error: "rate-limit" };
const { name, email, message } = parsed.data;
await prisma.contactMessage.create({ data: { name, email, message } });
try {
await recordAnalyticsEvent({ type: "CONTACT_SUBMITTED", path: "/contact" });
} catch (error) {
console.error("Contact analytics failed", error);
}
try {
await sendContactMessageEmail({ name, email, message });
return { success: true };
} catch (error) {
console.error("Contact email delivery failed", error);
return { success: true, warning: "email-not-sent" };
}
}
+17 -2
View File
@@ -1,8 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getActiveLocale, getDictionary, isLocale, type Locale } from "@/lib/i18n"; import { getActiveLocale, getDictionary, isLocale, type Locale } from "@/lib/i18n";
import { buildPageMetadata } from "@/lib/metadata"; import { buildPageMetadata } from "@/lib/metadata";
import { getContactChannels, isComingSoonMode } from "@/lib/site"; import { getContactChannels, isSiteClosedMode } from "@/lib/site";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import ContactForm from "@/components/public/contact-form";
export function generateMetadata({ params }: { params: { locale: string } }): Metadata { export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) { if (!isLocale(params.locale)) {
@@ -17,7 +18,7 @@ export default function ContactPage({ params }: { params: { locale: string } })
notFound(); notFound();
} }
if (isComingSoonMode()) { if (isSiteClosedMode()) {
redirect("/"); redirect("/");
} }
@@ -38,6 +39,20 @@ export default function ContactPage({ params }: { params: { locale: string } })
<p>{dictionary.contact.availabilityDescription}</p> <p>{dictionary.contact.availabilityDescription}</p>
</article> </article>
<ContactForm
labels={{
name: dictionary.contact.formName,
email: dictionary.contact.formEmail,
message: dictionary.contact.formMessage,
submit: dictionary.contact.formSubmit,
sending: dictionary.contact.formSending,
success: dictionary.contact.formSuccess,
savedWarning: dictionary.contact.formSavedWarning,
invalid: dictionary.contact.formInvalid,
rateLimit: dictionary.contact.formRateLimit,
}}
/>
<div className="section-heading"> <div className="section-heading">
<h2>{dictionary.contact.channelsTitle}</h2> <h2>{dictionary.contact.channelsTitle}</h2>
</div> </div>
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDetail from "@/components/public/project-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
import { getPublishedProject } from "@/lib/project-queries";
type DesignDetailPageProps = {
params: { locale: string; slug: string };
};
export function generateMetadata({ params }: DesignDetailPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "DESIGN");
return { title: copy.eyebrow, description: copy.description };
}
export default async function DesignDetailPage({ params }: DesignDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const project = await getPublishedProject("DESIGN", params.slug);
if (!project) notFound();
return <ProjectDetail locale={locale} type="DESIGN" project={project} />;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDirectory from "@/components/public/project-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
type DesignsPageProps = {
params: { locale: string };
searchParams: { category?: string };
};
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "DESIGN");
return { title: copy.title, description: copy.description };
}
export default function DesignsPage({ params, searchParams }: DesignsPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <ProjectDirectory locale={locale} type="DESIGN" categorySlug={searchParams.category} />;
}
+4 -2
View File
@@ -4,7 +4,8 @@ import BottomNav from "@/components/BottomNav";
import SiteFooter from "@/components/SiteFooter"; import SiteFooter from "@/components/SiteFooter";
import SiteHeader from "@/components/SiteHeader"; import SiteHeader from "@/components/SiteHeader";
import { getActiveLocale, getDictionary, getDirection, getEnabledLocales, isLocale, type Locale } from "@/lib/i18n"; 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; export const dynamicParams = false;
@@ -23,7 +24,7 @@ export default function LocaleLayout({
notFound(); notFound();
} }
if (isComingSoonMode()) { if (isSiteClosedMode()) {
redirect("/"); redirect("/");
} }
@@ -33,6 +34,7 @@ export default function LocaleLayout({
return ( return (
<div dir={getDirection(locale)} className="site-shell"> <div dir={getDirection(locale)} className="site-shell">
<SiteHeader locale={locale} common={dictionary.common} /> <SiteHeader locale={locale} common={dictionary.common} />
<AnalyticsTracker event="PAGE_VIEW" />
<main className="page-content">{children}</main> <main className="page-content">{children}</main>
<BottomNav locale={locale} common={dictionary.common} /> <BottomNav locale={locale} common={dictionary.common} />
<SiteFooter locale={locale} common={dictionary.common} /> <SiteFooter locale={locale} common={dictionary.common} />
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import LegalDocument from "@/components/public/legal-document";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getLegalDocument } from "@/lib/legal-content";
type LegalPageProps = { params: { locale: string } };
export function generateMetadata({ params }: LegalPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const document = getLegalDocument(getActiveLocale(params.locale as Locale), "impressum");
return { title: document.title, description: document.intro };
}
export default function ImpressumPage({ params }: LegalPageProps) {
if (!isLocale(params.locale)) notFound();
return <LegalDocument document={getLegalDocument(getActiveLocale(params.locale as Locale), "impressum")} />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import LegalDocument from "@/components/public/legal-document";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getLegalDocument } from "@/lib/legal-content";
type LegalPageProps = { params: { locale: string } };
export function generateMetadata({ params }: LegalPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const document = getLegalDocument(getActiveLocale(params.locale as Locale), "privacy");
return { title: document.title, description: document.intro };
}
export default function PrivacyPage({ params }: LegalPageProps) {
if (!isLocale(params.locale)) notFound();
return <LegalDocument document={getLegalDocument(getActiveLocale(params.locale as Locale), "privacy")} />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import LegalDocument from "@/components/public/legal-document";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getLegalDocument } from "@/lib/legal-content";
type LegalPageProps = { params: { locale: string } };
export function generateMetadata({ params }: LegalPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const document = getLegalDocument(getActiveLocale(params.locale as Locale), "terms");
return { title: document.title, description: document.intro };
}
export default function TermsPage({ params }: LegalPageProps) {
if (!isLocale(params.locale)) notFound();
return <LegalDocument document={getLegalDocument(getActiveLocale(params.locale as Locale), "terms")} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import MelodyDetail from "@/components/public/melody-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getPublishedMelody } from "@/lib/melody-queries";
type MelodyDetailPageProps = { params: { locale: string; slug: string } };
export async function generateMetadata({ params }: MelodyDetailPageProps): Promise<Metadata> {
if (!isLocale(params.locale)) return {};
const melody = await getPublishedMelody(params.slug);
if (!melody) return {};
return { title: getActiveLocale(params.locale as Locale) === "ar" ? melody.titleAr : melody.titleEn };
}
export default async function MelodyDetailPage({ params }: MelodyDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const melody = await getPublishedMelody(params.slug);
if (!melody) notFound();
return <MelodyDetail locale={locale} melody={melody} />;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import MelodyDirectory from "@/components/public/melody-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getMelodyDirectoryCopy } from "@/lib/melody-content";
type MelodiesPageProps = { params: { locale: string }; searchParams: { category?: string } };
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const copy = getMelodyDirectoryCopy(getActiveLocale(params.locale as Locale));
return { title: copy.title, description: copy.description };
}
export default function MelodiesPage({ params, searchParams }: MelodiesPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <MelodyDirectory locale={locale} categorySlug={searchParams.category} />;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDetail from "@/components/public/project-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
import { getPublishedProject } from "@/lib/project-queries";
type WebsiteDetailPageProps = {
params: { locale: string; slug: string };
};
export function generateMetadata({ params }: WebsiteDetailPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "WEBSITE");
return { title: copy.eyebrow, description: copy.description };
}
export default async function WebsiteDetailPage({ params }: WebsiteDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const project = await getPublishedProject("WEBSITE", params.slug);
if (!project) notFound();
return <ProjectDetail locale={locale} type="WEBSITE" project={project} />;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDirectory from "@/components/public/project-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
type WebsitesPageProps = {
params: { locale: string };
searchParams: { category?: string };
};
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "WEBSITE");
return { title: copy.title, description: copy.description };
}
export default function WebsitesPage({ params, searchParams }: WebsitesPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <ProjectDirectory locale={locale} type="WEBSITE" categorySlug={searchParams.category} />;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDetail from "@/components/public/project-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
import { getPublishedProject } from "@/lib/project-queries";
type WorkDetailPageProps = {
params: { locale: string; slug: string };
};
export function generateMetadata({ params }: WorkDetailPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "PORTFOLIO");
return { title: copy.eyebrow, description: copy.description };
}
export default async function WorkDetailPage({ params }: WorkDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const project = await getPublishedProject("PORTFOLIO", params.slug);
if (!project) notFound();
return <ProjectDetail locale={locale} type="PORTFOLIO" project={project} />;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDirectory from "@/components/public/project-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
type WorkPageProps = {
params: { locale: string };
searchParams: { category?: string };
};
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "PORTFOLIO");
return { title: copy.title, description: copy.description };
}
export default function WorkPage({ params, searchParams }: WorkPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <ProjectDirectory locale={locale} type="PORTFOLIO" categorySlug={searchParams.category} />;
}
+34
View File
@@ -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<string, string> = {
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 (
<div className="mx-auto w-full max-w-7xl">
<AdminPageHeader title="Analytics" description="Internal counters from the last 30 days. No third-party analytics service is used." />
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{Object.entries(eventLabels).map(([type, label]) => (
<Card key={type}><CardHeader className="pb-3"><CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle></CardHeader><CardContent><p className="text-3xl font-semibold">{grouped.find((item) => item.type === type)?.count ?? 0}</p><p className="mt-1 text-xs text-muted-foreground">Last 30 days</p></CardContent></Card>
))}
</div>
<Card className="mt-6">
<CardHeader><CardTitle>Recent events</CardTitle></CardHeader>
<CardContent>
{summary.recent.length === 0 ? <p className="text-sm text-muted-foreground">No events recorded yet.</p> : <div className="space-y-3">{summary.recent.map((event) => <div key={event.id} className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-3 text-sm last:border-0 last:pb-0"><span className="font-medium">{eventLabels[event.type] ?? event.type}</span><span className="text-muted-foreground">{event.path || "—"} · {event.createdAt.toLocaleString("en-GB")}</span></div>)}</div>}
</CardContent>
</Card>
<p className="mt-5 text-xs text-muted-foreground">Total events recorded: {summary.total}</p>
</div>
);
}
@@ -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 (
<div className="mx-auto w-full max-w-7xl">
<AdminPageHeader
title="Edit category"
description={`Update ${category.nameEn} and keep its slug stable for public links.`}
actions={
<Button asChild variant="outline">
<Link href="/admin/categories">Back to categories</Link>
</Button>
}
/>
<CategoryForm
mode="edit"
categoryId={category.id}
defaultValues={{
kind: category.kind,
slug: category.slug,
nameAr: category.nameAr,
nameEn: category.nameEn,
order: category.order,
}}
/>
</div>
);
}
@@ -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<CategoryActionResult> {
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<CategoryActionResult> {
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");
}
@@ -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 (
<div className="mx-auto w-full max-w-7xl">
<AdminPageHeader
title="New category"
description="Create a category that can be reused across the public content sections."
actions={
<Button asChild variant="outline">
<Link href="/admin/categories">Back to categories</Link>
</Button>
}
/>
<CategoryForm
mode="create"
defaultValues={{ kind: "PROJECT", slug: "", nameAr: "", nameEn: "", order: 0 }}
/>
</div>
);
}
+101
View File
@@ -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<CategoryKind, string> = {
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 (
<div className="mx-auto w-full max-w-7xl">
<AdminPageHeader
title="Categories"
description="Organize projects and melodies into reusable sections."
actions={
<Button asChild>
<Link href="/admin/categories/new">New category</Link>
</Button>
}
/>
{errorMessage ? (
<p className="mb-6 rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{errorMessage}
</p>
) : null}
{categories.length === 0 ? (
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground">No categories yet. Create the first one to organize content.</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{categories.map((category) => (
<Card key={category.id}>
<CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-brand-2">
{kindLabels[category.kind]}
</p>
<CardTitle className="mt-2 text-lg">{category.nameEn}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground" dir="rtl">
{category.nameAr}
</p>
</div>
<span className="rounded-pill border border-border px-2 py-1 text-xs text-muted-foreground">
#{category.order}
</span>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">/{category.slug}</p>
<p className="mt-3 text-sm text-muted-foreground">
{category._count.projects + category._count.melodies} linked items
</p>
<div className="mt-5 flex items-center gap-2">
<Button asChild size="sm" variant="outline">
<Link href={`/admin/categories/${category.id}/edit`}>Edit</Link>
</Button>
<form action={deleteCategory}>
<input type="hidden" name="categoryId" value={category.id} />
<Button type="submit" size="sm" variant="ghost">
Delete
</Button>
</form>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+23
View File
@@ -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 (
<AdminShell userEmail={session.user.email ?? "Admin"} signOutAction={handleSignOut}>
{children}
</AdminShell>
);
}
@@ -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 <div className="mx-auto w-full max-w-7xl"><AdminPageHeader title={`Edit ${melody.titleEn}`} description="Update the audio, artwork, category, and public visibility settings." actions={<Button asChild variant="outline"><Link href="/admin/melodies">Back to melodies</Link></Button>} /><MelodyForm mode="edit" melodyId={melody.id} categories={categories} defaultValues={defaultValues} /></div>;
}
+116
View File
@@ -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<MelodyActionResult> {
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<MelodyActionResult> {
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");
}
@@ -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 <div className="mx-auto w-full max-w-7xl"><AdminPageHeader title="New melody" description="Create an audio track with its cover, category, and publishing controls." actions={<Button asChild variant="outline"><Link href="/admin/melodies">Back to melodies</Link></Button>} /><MelodyForm mode="create" categories={categories} defaultValues={defaultValues} /></div>;
}
+53
View File
@@ -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 (
<div className="mx-auto w-full max-w-7xl">
<AdminPageHeader title="Melodies" description="Manage audio tracks, cover art, categories, publishing, and download permissions." actions={<Button asChild><Link href="/admin/melodies/new">New melody</Link></Button>} />
{errorMessage ? <p className="mb-6 rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMessage}</p> : null}
{melodies.length === 0 ? (
<Card><CardContent className="p-6"><p className="text-sm text-muted-foreground">No melodies yet.</p></CardContent></Card>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{melodies.map((melody) => (
<Card key={melody.id}>
<CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
<div><CardTitle className="text-lg">{melody.titleEn}</CardTitle><p className="mt-1 text-sm text-muted-foreground" dir="rtl">{melody.titleAr}</p></div>
<span className="rounded-pill border border-border px-2 py-1 text-xs text-muted-foreground">{melody.status}</span>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">/{melody.slug}</p>
<p className="mt-2 text-sm text-muted-foreground">{melody.category.nameEn} · {melody.category.nameAr}</p>
<p className="mt-2 text-xs text-muted-foreground">{melody.durationSec ? `${melody.durationSec}s` : "No duration"} · {melody.isDownloadable ? "Download enabled" : "Streaming only"}</p>
<div className="mt-5 flex items-center gap-2">
<Button asChild size="sm" variant="outline"><Link href={`/admin/melodies/${melody.id}/edit`}>Edit</Link></Button>
<form action={deleteMelody}><input type="hidden" name="melodyId" value={melody.id} /><Button type="submit" size="sm" variant="ghost">Delete</Button></form>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+48
View File
@@ -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 (
<div className="mx-auto w-full max-w-7xl">
<AdminPageHeader
title="Dashboard"
description="Manage the content that will power the public website."
/>
<section aria-label="Workspace overview" className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-base">Signed-in account</CardTitle>
</CardHeader>
<CardContent>
<p className="truncate text-sm text-muted-foreground">{session.user.email}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Content status</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Coming soon mode is active.</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Next step</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">Categories and content tools are next.</p>
</CardContent>
</Card>
</section>
</div>
);
}
@@ -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 (
<div className="mx-auto w-full max-w-7xl">
<AdminPageHeader
title={`Edit ${project.titleEn}`}
description="Update the project details, media paths, category, and public visibility settings."
actions={
<Button asChild variant="outline">
<Link href="/admin/projects">Back to projects</Link>
</Button>
}
/>
<ProjectForm mode="edit" projectId={project.id} categories={categories} defaultValues={defaultValues} />
</div>
);
}
+150
View File
@@ -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<ProjectActionResult> {
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<ProjectActionResult> {
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");
}
@@ -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 (
<div className="mx-auto w-full max-w-7xl">
<AdminPageHeader
title="New project"
description="Create a project or app record with media, links, category, and publishing controls."
actions={
<Button asChild variant="outline">
<Link href="/admin/projects">Back to projects</Link>
</Button>
}
/>
<ProjectForm mode="create" categories={categories} defaultValues={defaultValues} />
</div>
);
}
+113
View File
@@ -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<ProjectType, string> = {
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 (
<div className="mx-auto w-full max-w-7xl">
<AdminPageHeader
title="Projects"
description="Manage portfolio work, apps, websites, designs, and the project records used by public pages."
actions={
<Button asChild>
<Link href="/admin/projects/new">New project</Link>
</Button>
}
/>
{errorMessage ? (
<p className="mb-6 rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{errorMessage}
</p>
) : null}
<div className="mb-6 flex flex-wrap gap-2">
<Button asChild size="sm" variant={!selectedType ? "default" : "outline"}>
<Link href="/admin/projects">All</Link>
</Button>
{Object.entries(typeLabels).map(([value, label]) => (
<Button key={value} asChild size="sm" variant={selectedType === value ? "default" : "outline"}>
<Link href={`/admin/projects?type=${value}`}>{label}</Link>
</Button>
))}
</div>
{projects.length === 0 ? (
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground">No projects match this filter yet.</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<Card key={project.id}>
<CardHeader className="flex-row items-start justify-between gap-4 space-y-0">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-brand-2">{typeLabels[project.type]}</p>
<CardTitle className="mt-2 text-lg">{project.titleEn}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground" dir="rtl">
{project.titleAr}
</p>
</div>
<span className="rounded-pill border border-border px-2 py-1 text-xs text-muted-foreground">{project.status}</span>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">/{project.slug}</p>
<p className="mt-2 text-sm text-muted-foreground">
{project.category ? `${project.category.nameEn} · ${project.category.nameAr}` : "No category"}
</p>
<p className="mt-2 text-xs text-muted-foreground">
{project.images.length} images · {project.technologies.length} technologies
</p>
<div className="mt-5 flex items-center gap-2">
<Button asChild size="sm" variant="outline">
<Link href={`/admin/projects/${project.id}/edit`}>Edit</Link>
</Button>
<form action={deleteProject}>
<input type="hidden" name="projectId" value={project.id} />
<Button type="submit" size="sm" variant="ghost">
Delete
</Button>
</form>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+17
View File
@@ -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 (
<main className="flex min-h-screen items-center justify-center bg-background p-6 text-foreground">
<LoginForm />
</main>
);
}
+19
View File
@@ -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 });
}
}
+3
View File
@@ -0,0 +1,3 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
+33
View File
@@ -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 });
}
}
+4
View File
@@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root { :root {
--bg: #07111f; --bg: #07111f;
--surface: rgba(11, 20, 36, 0.84); --surface: rgba(11, 20, 36, 0.84);
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1648.73 1490.2">
<defs>
<style>
.cls-1 {
fill: #f04444;
}
</style>
</defs>
<path class="cls-1" d="M939.33,1029.38h617.52c30.96,0,56.1,25.14,56.1,56.1v126.04c0,30.96-25.14,56.1-56.1,56.1h-611.42c-30.96,0-56.1-25.14-56.1-56.1v-132.14c0-27.6,22.4-50,50-50Z" transform="translate(1178.56 -548.3) rotate(45)"/>
<path class="cls-1" d="M1581.03,76.36c7.64,6.41,8.15,18,1.09,25.05l-210.72,210.72-807.43,807.43-47.17,47.17-283.48,283.48c-21.91,21.91-57.43,21.91-79.33,0l-89.61-89.61c-21.91-21.91-21.91-57.43,0-79.33l244.92-244.92c10.52-10.52,16.43-24.79,16.43-39.67v-484.91c0-30.98,25.12-56.1,56.1-56.1h126.04c30.98,0,56.1,25.12,56.1,56.1v134.48c0,49.98,60.42,75.01,95.76,39.67l278.02-278.02c35.34-35.34,10.31-95.76-39.67-95.76H80.81c-30.98,0-56.1-25.12-56.1-56.1v-127.55c0-30.98,25.12-56.1,56.1-56.1h1489.28c4,0,7.87,1.41,10.93,3.98h0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 998 B

+8 -9
View File
@@ -1,11 +1,10 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { siteConfig } from "@/lib/site"; import { isSiteClosedMode, siteConfig } from "@/lib/site";
import { isComingSoonMode } from "@/lib/site";
import "./globals.css"; import "./globals.css";
export function generateMetadata(): Metadata { export function generateMetadata(): Metadata {
const comingSoon = isComingSoonMode(); const siteClosed = isSiteClosedMode();
return { return {
metadataBase: new URL(siteConfig.siteUrl), metadataBase: new URL(siteConfig.siteUrl),
@@ -13,7 +12,7 @@ export function generateMetadata(): Metadata {
default: "Diyaa", default: "Diyaa",
template: "%s | Diyaa", template: "%s | Diyaa",
}, },
description: comingSoon description: siteClosed
? "Minimal coming soon page for the upcoming launch." ? "Minimal coming soon page for the upcoming launch."
: "Bilingual professional website built for private-server deployment.", : "Bilingual professional website built for private-server deployment.",
applicationName: "Diyaa", applicationName: "Diyaa",
@@ -21,7 +20,7 @@ export function generateMetadata(): Metadata {
creator: "Diyaa", creator: "Diyaa",
publisher: "Diyaa", publisher: "Diyaa",
alternates: { alternates: {
languages: comingSoon languages: siteClosed
? { ? {
en: "/", en: "/",
"x-default": "/", "x-default": "/",
@@ -36,11 +35,11 @@ export function generateMetadata(): Metadata {
} }
function getThemeScript() { function getThemeScript() {
const comingSoon = isComingSoonMode(); const siteClosed = isSiteClosedMode();
return ` 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"; const direction = locale === "ar" ? "rtl" : "ltr";
document.documentElement.lang = locale; document.documentElement.lang = locale;
document.documentElement.dir = direction; document.documentElement.dir = direction;
@@ -56,10 +55,10 @@ function getThemeScript() {
} }
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
const comingSoon = isComingSoonMode(); const siteClosed = isSiteClosedMode();
return ( return (
<html lang={comingSoon ? "en" : "ar"} dir={comingSoon ? "ltr" : "rtl"} data-theme="dark" suppressHydrationWarning> <html lang={siteClosed ? "en" : "ar"} dir={siteClosed ? "ltr" : "rtl"} data-theme="dark" suppressHydrationWarning>
<head> <head>
<script dangerouslySetInnerHTML={{ __html: getThemeScript() }} /> <script dangerouslySetInnerHTML={{ __html: getThemeScript() }} />
</head> </head>
+4 -4
View File
@@ -1,8 +1,8 @@
import Link from "next/link"; import Link from "next/link";
import { isComingSoonMode } from "@/lib/site"; import { isSiteClosedMode } from "@/lib/site";
export default function NotFound() { export default function NotFound() {
const comingSoon = isComingSoonMode(); const siteClosed = isSiteClosedMode();
return ( return (
<main className="page-content"> <main className="page-content">
@@ -11,14 +11,14 @@ export default function NotFound() {
<p className="eyebrow">404</p> <p className="eyebrow">404</p>
<h1>الصفحة غير موجودة</h1> <h1>الصفحة غير موجودة</h1>
<p className="lead"> <p className="lead">
{comingSoon {siteClosed
? "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى الصفحة الرئيسية." ? "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى الصفحة الرئيسية."
: "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى النسخة العربية أو الإنجليزية من الصفحة الرئيسية."} : "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى النسخة العربية أو الإنجليزية من الصفحة الرئيسية."}
</p> </p>
</div> </div>
<div className="cta-row"> <div className="cta-row">
{comingSoon ? ( {siteClosed ? (
<Link href="/" className="cta-btn"> <Link href="/" className="cta-btn">
العودة إلى الرئيسية العودة إلى الرئيسية
</Link> </Link>
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

+3 -3
View File
@@ -1,11 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { isComingSoonMode } from "@/lib/site"; import { isSiteClosedMode } from "@/lib/site";
export default function RootPage() { export default function RootPage() {
if (isComingSoonMode()) { if (isSiteClosedMode()) {
return ( return (
<main className="coming-soon-page"> <main className="coming-soon-page">
<h1 className="coming-soon-title">Coming Soon</h1> <h1 className="coming-soon-title">Coming Soon123</h1>
</main> </main>
); );
} }
+5 -6
View File
@@ -1,16 +1,15 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { getLocalizedPath, siteConfig } from "@/lib/site"; import { getLocalizedPath, isSiteClosedMode, siteConfig } from "@/lib/site";
import { isComingSoonMode } from "@/lib/site";
import type { Locale } from "@/lib/i18n"; 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; const allLocales = ["ar", "en"] as const;
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date(); const lastModified = new Date();
const comingSoon = isComingSoonMode(); const siteClosed = isSiteClosedMode();
const pages: readonly (typeof allPages)[number][] = comingSoon ? [] : allPages; const pages: readonly (typeof allPages)[number][] = siteClosed ? [] : allPages;
const locales: readonly Locale[] = comingSoon ? [] : allLocales; const locales: readonly Locale[] = siteClosed ? [] : allLocales;
return [ return [
{ {
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

+20
View File
@@ -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"
}
}
+11 -1
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import type { Locale } from "@/lib/i18n"; import type { Locale } from "@/lib/i18n";
import type { CommonContent } from "@/content/types"; import type { CommonContent } from "@/content/types";
import { getModeValue, isComingSoonMode } from "@/lib/site"; import { getModeValue, isComingSoonMode } from "@/lib/site";
@@ -19,7 +20,16 @@ export default function SiteFooter({ locale, common }: SiteFooterProps) {
<p>{common.footerRights.replace("{year}", String(year))}</p> <p>{common.footerRights.replace("{year}", String(year))}</p>
<p>{commonVariant.footerBuiltWith}</p> <p>{commonVariant.footerBuiltWith}</p>
</div> </div>
{!isComingSoon ? <p className="locale-badge">{locale.toUpperCase()}</p> : null} {!isComingSoon ? (
<div className="flex flex-wrap items-center gap-4">
<nav aria-label={common.legal.label} className="flex flex-wrap gap-3 text-sm">
<Link href={`/${locale}/legal/privacy`} className="hover:text-foreground hover:underline">{common.legal.privacy}</Link>
<Link href={`/${locale}/legal/terms`} className="hover:text-foreground hover:underline">{common.legal.terms}</Link>
<Link href={`/${locale}/legal/impressum`} className="hover:text-foreground hover:underline">{common.legal.impressum}</Link>
</nav>
<p className="locale-badge">{locale.toUpperCase()}</p>
</div>
) : null}
</div> </div>
</footer> </footer>
); );
+3
View File
@@ -27,6 +27,9 @@ export default function SiteHeader({ locale, common }: SiteHeaderProps) {
{!isComingSoon ? ( {!isComingSoon ? (
<nav className="header-nav" aria-label={common.navLabel}> <nav className="header-nav" aria-label={common.navLabel}>
<Link href={`/${locale}`}>{common.nav.home}</Link> <Link href={`/${locale}`}>{common.nav.home}</Link>
<Link href={`/${locale}/work`}>{common.nav.projects}</Link>
<Link href={`/${locale}/apps`}>{common.nav.apps}</Link>
<Link href={`/${locale}/melodies`}>{common.nav.melodies}</Link>
<Link href={`/${locale}/about`}>{common.nav.about}</Link> <Link href={`/${locale}/about`}>{common.nav.about}</Link>
<Link href={`/${locale}/contact`}>{common.nav.contact}</Link> <Link href={`/${locale}/contact`}>{common.nav.contact}</Link>
</nav> </nav>
+36
View File
@@ -0,0 +1,36 @@
"use client";
import type { ReactNode } from "react";
import type { FieldValues, SubmitHandler, UseFormReturn } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { cn } from "@/lib/utils";
type AdminFormProps<TFieldValues extends FieldValues> = {
form: UseFormReturn<TFieldValues>;
onSubmit: SubmitHandler<TFieldValues>;
children: ReactNode;
submitLabel: string;
submittingLabel?: string;
className?: string;
};
export default function AdminForm<TFieldValues extends FieldValues>({
form,
onSubmit,
children,
submitLabel,
submittingLabel = "Saving…",
className,
}: AdminFormProps<TFieldValues>) {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-5", className)} noValidate>
{children}
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? submittingLabel : submitLabel}
</Button>
</form>
</Form>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
type AdminPageHeaderProps = {
eyebrow?: string;
title: string;
description?: string;
actions?: ReactNode;
};
export default function AdminPageHeader({ eyebrow = "Admin workspace", title, description, actions }: AdminPageHeaderProps) {
return (
<header className="mb-8 flex flex-col gap-4 border-b border-border pb-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.16em] text-brand-2">{eyebrow}</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="mt-2 max-w-2xl text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</header>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
type AdminShellProps = {
children: ReactNode;
userEmail: string;
signOutAction: (formData: FormData) => Promise<void>;
};
const navigation = [
{ label: "Dashboard", href: "/admin" },
{ label: "Categories", href: "/admin/categories" },
{ label: "Projects", href: "/admin/projects" },
{ label: "Apps", href: "/admin/projects?type=APP" },
{ label: "Melodies", href: "/admin/melodies" },
{ label: "Analytics", href: "/admin/analytics" },
{ label: "Media", active: false },
];
export default function AdminShell({ children, userEmail, signOutAction }: AdminShellProps) {
const pathname = usePathname();
return (
<div className="min-h-screen bg-background text-foreground lg:grid lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside className="border-b border-border bg-card lg:sticky lg:top-0 lg:h-screen lg:border-b-0 lg:border-e">
<div className="flex h-full flex-col gap-8 p-5 sm:p-6 lg:p-5">
<div>
<Link href="/admin" className="text-lg font-bold tracking-tight">
Diyaa Admin
</Link>
<p className="mt-1 text-sm text-muted-foreground">Content workspace</p>
</div>
<nav aria-label="Admin navigation" className="grid gap-1">
{navigation.map((item) =>
item.href ? (
<Link
key={item.label}
href={item.href ?? "/admin"}
className={
pathname === item.href.split("?")[0] || (item.href.split("?")[0] !== "/admin" && pathname.startsWith(`${item.href.split("?")[0]}/`))
? "rounded-md bg-accent px-3 py-2 text-sm font-medium text-accent-foreground"
: "rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
}
>
{item.label}
</Link>
) : (
<span
key={item.label}
aria-disabled="true"
className="flex cursor-not-allowed items-center justify-between rounded-md px-3 py-2 text-sm text-muted-foreground opacity-60"
>
{item.label}
<span className="text-xs">Soon</span>
</span>
),
)}
</nav>
<div className="mt-auto border-t border-border pt-5">
<p className="truncate text-sm text-muted-foreground" title={userEmail}>
{userEmail}
</p>
<form action={signOutAction} className="mt-3">
<Button type="submit" variant="outline" className="w-full">
Sign out
</Button>
</form>
</div>
</div>
</aside>
<main className="min-w-0 p-4 sm:p-6 lg:p-8">{children}</main>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AdminForm from "@/components/admin/admin-form";
import { createCategory, updateCategory } from "@/app/admin/(protected)/categories/actions";
import { categorySchema, type CategoryInput } from "@/lib/validations/category";
import { useRouter } from "next/navigation";
type CategoryFormProps = {
mode: "create" | "edit";
categoryId?: string;
defaultValues: CategoryInput;
};
const kindLabels: Record<CategoryInput["kind"], string> = {
PROJECT: "Projects",
MELODY: "Melodies",
};
export default function CategoryForm({ mode, categoryId, defaultValues }: CategoryFormProps) {
const router = useRouter();
const [serverError, setServerError] = useState<string | null>(null);
const form = useForm<CategoryInput>({
resolver: zodResolver(categorySchema),
defaultValues,
});
const onSubmit = async (values: CategoryInput) => {
setServerError(null);
const result =
mode === "create" ? await createCategory(values) : await updateCategory(categoryId ?? "", values);
if (result.error) {
setServerError(result.error);
return;
}
router.push("/admin/categories");
router.refresh();
};
return (
<AdminForm
form={form}
onSubmit={onSubmit}
submitLabel={mode === "create" ? "Create category" : "Save changes"}
submittingLabel="Saving…"
className="max-w-2xl"
>
<FormField
control={form.control}
name="kind"
render={({ field }) => (
<FormItem>
<FormLabel>Section</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose a section" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(kindLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="web-design" autoComplete="off" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-5 sm:grid-cols-2">
<FormField
control={form.control}
name="nameAr"
render={({ field }) => (
<FormItem>
<FormLabel>Arabic name</FormLabel>
<FormControl>
<Input dir="rtl" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameEn"
render={({ field }) => (
<FormItem>
<FormLabel>English name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="order"
render={({ field }) => (
<FormItem>
<FormLabel>Display order</FormLabel>
<FormControl>
<Input
type="number"
min={0}
max={9999}
inputMode="numeric"
{...field}
onChange={(event) => field.onChange(event.target.valueAsNumber || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{serverError ? <p className="text-sm font-medium text-destructive">{serverError}</p> : null}
</AdminForm>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
uploadedFileSchema,
UPLOAD_RULES,
type UploadKind,
type UploadedFileMetadata,
} from "@/lib/validations/upload";
type FileUploadProps = {
kind: UploadKind;
onUploaded: (files: UploadedFileMetadata[]) => void;
id?: string;
multiple?: boolean;
label?: string;
helperText?: string;
};
const defaultLabels: Record<UploadKind, string> = {
image: "Image",
audio: "Audio",
};
export default function FileUpload({
kind,
onUploaded,
id,
multiple = false,
label,
helperText,
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const rules = UPLOAD_RULES[kind];
const fieldId = id ?? `file-upload-${kind}`;
async function handleFiles(selectedFiles: File[]) {
setError(null);
setIsUploading(true);
try {
const uploaded: UploadedFileMetadata[] = [];
for (const file of selectedFiles) {
if (!rules.mimeTypes.includes(file.type) || file.size > rules.maxBytes) {
throw new Error(`Unsupported or oversized ${kind} file.`);
}
const body = new FormData();
body.set("kind", kind);
body.set("file", file);
const response = await fetch("/api/upload", { method: "POST", body });
const payload: unknown = await response.json();
if (!response.ok) {
const message =
typeof payload === "object" && payload !== null && "error" in payload
? String(payload.error)
: "The file could not be uploaded.";
throw new Error(message);
}
const parsed = uploadedFileSchema.safeParse(payload);
if (!parsed.success) {
throw new Error("The upload response was invalid.");
}
uploaded.push(parsed.data);
}
onUploaded(uploaded);
} catch (uploadError) {
setError(uploadError instanceof Error ? uploadError.message : "The file could not be uploaded.");
} finally {
setIsUploading(false);
if (inputRef.current) {
inputRef.current.value = "";
}
}
}
return (
<div className="space-y-2" dir="auto">
<Label htmlFor={fieldId}>{label ?? defaultLabels[kind]}</Label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
ref={inputRef}
id={fieldId}
type="file"
accept={rules.accept}
multiple={multiple}
disabled={isUploading}
onChange={(event) => {
void handleFiles(Array.from(event.target.files ?? []));
}}
/>
{isUploading ? (
<Button type="button" variant="secondary" disabled>
Uploading
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{helperText ?? `Maximum ${Math.round(rules.maxBytes / (1024 * 1024))} MB.`}
</p>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import AdminForm from "@/components/admin/admin-form";
import { loginSchema, type LoginInput } from "@/lib/validations/auth";
export default function LoginForm() {
const [serverError, setServerError] = useState<string | null>(null);
const form = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
});
const onSubmit = async (values: LoginInput) => {
setServerError(null);
const result = await signIn("credentials", {
email: values.email,
password: values.password,
redirect: false,
callbackUrl: "/admin",
});
if (!result || result.error) {
setServerError("The email or password is incorrect.");
return;
}
window.location.assign(result.url ?? "/admin");
};
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Admin sign in</CardTitle>
<CardDescription>Use your admin credentials to continue.</CardDescription>
</CardHeader>
<CardContent>
<AdminForm form={form} onSubmit={onSubmit} submitLabel="Sign in" submittingLabel="Signing in…" className="space-y-5">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" autoComplete="current-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{serverError ? <p className="text-sm font-medium text-destructive">{serverError}</p> : null}
</AdminForm>
</CardContent>
</Card>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import AdminForm from "@/components/admin/admin-form";
import FileUpload from "@/components/admin/file-upload";
import { createMelody, updateMelody } from "@/app/admin/(protected)/melodies/actions";
import { melodySchema, type MelodyInput } from "@/lib/validations/melody";
type CategoryOption = { id: string; nameAr: string; nameEn: string };
type MelodyFormProps = {
mode: "create" | "edit";
melodyId?: string;
categories: CategoryOption[];
defaultValues: MelodyInput;
};
const statusLabels: Record<MelodyInput["status"], string> = {
DRAFT: "Draft",
PUBLISHED: "Published",
ARCHIVED: "Archived",
};
export default function MelodyForm({ mode, melodyId, categories, defaultValues }: MelodyFormProps) {
const router = useRouter();
const [serverError, setServerError] = useState<string | null>(null);
const form = useForm<MelodyInput>({ resolver: zodResolver(melodySchema), defaultValues });
const onSubmit = async (values: MelodyInput) => {
setServerError(null);
const result = mode === "create" ? await createMelody(values) : await updateMelody(melodyId ?? "", values);
if (result.error) {
setServerError(result.error);
return;
}
router.push("/admin/melodies");
router.refresh();
};
return (
<AdminForm
form={form}
onSubmit={onSubmit}
submitLabel={mode === "create" ? "Create melody" : "Save changes"}
submittingLabel="Saving…"
className="max-w-3xl"
>
<div className="grid gap-5 md:grid-cols-2">
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl><SelectTrigger><SelectValue placeholder="Choose a status" /></SelectTrigger></FormControl>
<SelectContent>{Object.entries(statusLabels).map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="categoryId" render={({ field }) => (
<FormItem>
<FormLabel>Melody category</FormLabel>
<Select value={field.value || "__none"} onValueChange={(value) => field.onChange(value === "__none" ? "" : value)}>
<FormControl><SelectTrigger><SelectValue placeholder="Choose a category" /></SelectTrigger></FormControl>
<SelectContent>
<SelectItem value="__none">Choose a category</SelectItem>
{categories.map((category) => <SelectItem key={category.id} value={category.id}>{category.nameEn} · {category.nameAr}</SelectItem>)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
</div>
<FormField control={form.control} name="slug" render={({ field }) => (
<FormItem><FormLabel>Slug</FormLabel><FormControl><Input placeholder="melody-slug" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<div className="grid gap-5 md:grid-cols-2">
<FormField control={form.control} name="titleEn" render={({ field }) => (
<FormItem><FormLabel>English title</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="titleAr" render={({ field }) => (
<FormItem><FormLabel>Arabic title</FormLabel><FormControl><Input dir="rtl" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField control={form.control} name="descEn" render={({ field }) => (
<FormItem><FormLabel>English description</FormLabel><FormControl><textarea className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" {...field} value={field.value ?? ""} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="descAr" render={({ field }) => (
<FormItem><FormLabel>Arabic description</FormLabel><FormControl><textarea dir="rtl" className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" {...field} value={field.value ?? ""} /></FormControl><FormMessage /></FormItem>
)} />
</div>
<FormField control={form.control} name="audioFile" render={({ field }) => (
<FormItem>
<FormLabel>Audio file path</FormLabel>
<FormControl><Input placeholder="/api/uploads/audios/..." {...field} /></FormControl>
<FormMessage />
<FileUpload id="melody-audio-file" kind="audio" label="Upload audio file" onUploaded={(files) => field.onChange(files[0]?.url ?? field.value)} />
</FormItem>
)} />
<div className="grid gap-5 md:grid-cols-2">
<FormField control={form.control} name="coverImage" render={({ field }) => (
<FormItem>
<FormLabel>Cover image path</FormLabel>
<FormControl><Input placeholder="/api/uploads/images/..." {...field} value={field.value ?? ""} /></FormControl>
<FormMessage />
<FileUpload id="melody-cover-image" kind="image" label="Upload cover image" onUploaded={(files) => field.onChange(files[0]?.url ?? field.value ?? "")} />
</FormItem>
)} />
<FormField control={form.control} name="durationSec" render={({ field }) => (
<FormItem>
<FormLabel>Duration (seconds)</FormLabel>
<FormControl><Input type="number" min={0} max={86400} value={field.value ?? ""} onChange={(event) => field.onChange(event.target.value ? event.target.valueAsNumber : null)} /></FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3 md:grid-cols-2">
<FormField control={form.control} name="isDownloadable" render={({ field }) => (
<FormItem className="flex items-center gap-3 rounded-md border border-border p-3">
<FormControl><Input type="checkbox" checked={field.value} onChange={(event) => field.onChange(event.target.checked)} className="h-4 w-4" /></FormControl>
<FormLabel className="m-0">Allow audio download</FormLabel>
</FormItem>
)} />
<FormField control={form.control} name="isFeatured" render={({ field }) => (
<FormItem className="flex items-center gap-3 rounded-md border border-border p-3">
<FormControl><Input type="checkbox" checked={field.value} onChange={(event) => field.onChange(event.target.checked)} className="h-4 w-4" /></FormControl>
<FormLabel className="m-0">Feature this melody</FormLabel>
</FormItem>
)} />
</div>
<FormField control={form.control} name="sortOrder" render={({ field }) => (
<FormItem><FormLabel>Sort order</FormLabel><FormControl><Input type="number" min={0} max={9999} value={field.value} onChange={(event) => field.onChange(event.target.valueAsNumber || 0)} /></FormControl><FormMessage /></FormItem>
)} />
{serverError ? <p className="text-sm font-medium text-destructive">{serverError}</p> : null}
</AdminForm>
);
}
+452
View File
@@ -0,0 +1,452 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AdminForm from "@/components/admin/admin-form";
import FileUpload from "@/components/admin/file-upload";
import { createProject, updateProject } from "@/app/admin/(protected)/projects/actions";
import { projectSchema, type ProjectInput } from "@/lib/validations/project";
import { useRouter } from "next/navigation";
type CategoryOption = {
id: string;
nameAr: string;
nameEn: string;
};
type ProjectFormProps = {
mode: "create" | "edit";
projectId?: string;
categories: CategoryOption[];
defaultValues: ProjectInput;
};
const typeLabels: Record<ProjectInput["type"], string> = {
PORTFOLIO: "Portfolio",
APP: "App",
WEBSITE: "Website",
DESIGN: "Design",
};
const statusLabels: Record<ProjectInput["status"], string> = {
DRAFT: "Draft",
PUBLISHED: "Published",
ARCHIVED: "Archived",
};
function parseList(value: string) {
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
export default function ProjectForm({ mode, projectId, categories, defaultValues }: ProjectFormProps) {
const router = useRouter();
const [serverError, setServerError] = useState<string | null>(null);
const form = useForm<ProjectInput>({
resolver: zodResolver(projectSchema),
defaultValues,
});
const projectType = form.watch("type");
const onSubmit = async (values: ProjectInput) => {
setServerError(null);
const result = mode === "create" ? await createProject(values) : await updateProject(projectId ?? "", values);
if (result.error) {
setServerError(result.error);
return;
}
router.push("/admin/projects");
router.refresh();
};
return (
<AdminForm
form={form}
onSubmit={onSubmit}
submitLabel={mode === "create" ? "Create project" : "Save changes"}
submittingLabel="Saving…"
className="max-w-3xl"
>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Project type</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose a type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(typeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(statusLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="project-slug" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="titleEn"
render={({ field }) => (
<FormItem>
<FormLabel>English title</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="titleAr"
render={({ field }) => (
<FormItem>
<FormLabel>Arabic title</FormLabel>
<FormControl>
<Input dir="rtl" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="summaryEn"
render={({ field }) => (
<FormItem>
<FormLabel>English summary</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="summaryAr"
render={({ field }) => (
<FormItem>
<FormLabel>Arabic summary</FormLabel>
<FormControl>
<Input dir="rtl" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="descEn"
render={({ field }) => (
<FormItem>
<FormLabel>English description</FormLabel>
<FormControl>
<textarea className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="descAr"
render={({ field }) => (
<FormItem>
<FormLabel>Arabic description</FormLabel>
<FormControl>
<textarea dir="rtl" className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Project category</FormLabel>
<Select value={field.value || "__none"} onValueChange={(value) => field.onChange(value === "__none" ? "" : value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose a category" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="__none">No category</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.nameEn} · {category.nameAr}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="coverImage"
render={({ field }) => (
<FormItem>
<FormLabel>Cover image path</FormLabel>
<FormControl>
<Input placeholder="/api/uploads/images/..." {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
<FileUpload
id="project-cover-image"
kind="image"
label="Upload cover image"
onUploaded={(files) => field.onChange(files[0]?.url ?? field.value ?? "")}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="images"
render={({ field }) => (
<FormItem>
<FormLabel>Gallery image paths</FormLabel>
<FormControl>
<textarea
className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="One uploaded path per line"
value={field.value.join("\n")}
onChange={(event) => field.onChange(parseList(event.target.value))}
/>
</FormControl>
<FormMessage />
<FileUpload
id="project-gallery-images"
kind="image"
label="Upload gallery images"
multiple
onUploaded={(files) => field.onChange([...field.value, ...files.map((file) => file.url)])}
/>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="technologies"
render={({ field }) => (
<FormItem>
<FormLabel>Technologies</FormLabel>
<FormControl>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Next.js, TypeScript, PostgreSQL"
value={field.value.join("\n")}
onChange={(event) => field.onChange(parseList(event.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="externalUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Live / external URL</FormLabel>
<FormControl>
<Input type="url" placeholder="https://example.com" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Repository URL</FormLabel>
<FormControl>
<Input type="url" placeholder="https://github.com/..." {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="sortOrder"
render={({ field }) => (
<FormItem>
<FormLabel>Sort order</FormLabel>
<FormControl>
<Input type="number" min={0} max={9999} value={field.value} onChange={(event) => field.onChange(event.target.valueAsNumber || 0)} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isFeatured"
render={({ field }) => (
<FormItem className="flex items-center gap-3 rounded-md border border-border p-3 md:mt-7">
<FormControl>
<Input type="checkbox" checked={field.value} onChange={(event) => field.onChange(event.target.checked)} className="h-4 w-4" />
</FormControl>
<FormLabel className="m-0">Feature this project</FormLabel>
</FormItem>
)}
/>
</div>
{projectType === "APP" ? (
<section className="space-y-5 rounded-xl border border-border bg-card p-5">
<div>
<h2 className="text-lg font-semibold">iOS app details</h2>
<p className="mt-1 text-sm text-muted-foreground">Add the store, release, and support details for this application.</p>
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="platform"
render={({ field }) => (
<FormItem>
<FormLabel>Platform</FormLabel>
<FormControl>
<Input placeholder="iOS / Swift" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appVersion"
render={({ field }) => (
<FormItem>
<FormLabel>App version</FormLabel>
<FormControl>
<Input placeholder="1.0.0" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
{([
["appStoreUrl", "App Store URL"],
["testflightUrl", "TestFlight URL"],
["supportUrl", "Support URL"],
["appPrivacyUrl", "App privacy URL"],
] as const).map(([name, label]) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input type="url" placeholder="https://..." {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</section>
) : null}
{serverError ? <p className="text-sm font-medium text-destructive">{serverError}</p> : null}
</AdminForm>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Body, Container, Head, Heading, Html, Link, Preview, Section, Text } from "@react-email/components";
type ContactMessageEmailProps = {
name: string;
email: string;
message: string;
siteUrl: string;
};
export default function ContactMessageEmail({ name, email, message, siteUrl }: ContactMessageEmailProps) {
return (
<Html>
<Head />
<Preview>New contact message from {name}</Preview>
<Body style={{ backgroundColor: "#f4f4f5", fontFamily: "Arial, sans-serif", padding: "32px 0" }}>
<Container style={{ backgroundColor: "#ffffff", margin: "0 auto", maxWidth: "560px", padding: "32px" }}>
<Heading style={{ color: "#18181b", fontSize: "24px" }}>New contact message</Heading>
<Text style={{ color: "#52525b", fontSize: "15px" }}>Someone sent a new message through your website.</Text>
<Section style={{ borderTop: "1px solid #e4e4e7", marginTop: "24px", paddingTop: "20px" }}>
<Text style={{ color: "#18181b", fontSize: "15px", margin: "8px 0" }}><strong>Name:</strong> {name}</Text>
<Text style={{ color: "#18181b", fontSize: "15px", margin: "8px 0" }}><strong>Email:</strong> <Link href={`mailto:${email}`}>{email}</Link></Text>
<Text style={{ color: "#18181b", fontSize: "15px", lineHeight: "1.6", whiteSpace: "pre-wrap" }}><strong>Message:</strong><br />{message}</Text>
</Section>
<Text style={{ color: "#71717a", fontSize: "12px", marginTop: "28px" }}><Link href={siteUrl}>Open website</Link></Text>
</Container>
</Body>
</Html>
);
}
export function ContactConfirmationEmail({ name, siteUrl }: Pick<ContactMessageEmailProps, "name" | "siteUrl">) {
return (
<Html>
<Head />
<Preview>Thanks for contacting Diyaa</Preview>
<Body style={{ backgroundColor: "#f4f4f5", fontFamily: "Arial, sans-serif", padding: "32px 0" }}>
<Container style={{ backgroundColor: "#ffffff", margin: "0 auto", maxWidth: "560px", padding: "32px" }}>
<Heading style={{ color: "#18181b", fontSize: "24px" }}>Thanks for getting in touch</Heading>
<Text style={{ color: "#52525b", fontSize: "15px", lineHeight: "1.6" }}>Hi {name}, your message was received. I will get back to you as soon as possible.</Text>
<Text style={{ color: "#71717a", fontSize: "12px", marginTop: "28px" }}><Link href={siteUrl}>Visit the website</Link></Text>
</Container>
</Body>
</Html>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { useEffect } from "react";
import { trackClientEvent } from "@/lib/analytics-client";
type AnalyticsTrackerProps = {
event: "PAGE_VIEW" | "PROJECT_OPEN";
entityId?: string;
};
export default function AnalyticsTracker({ event, entityId }: AnalyticsTrackerProps) {
useEffect(() => {
trackClientEvent({ type: event, entityId });
}, [event, entityId]);
return null;
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { trackClientEvent } from "@/lib/analytics-client";
type AudioPlayerProps = {
src: string;
title: string;
locale: "ar" | "en";
downloadable?: boolean;
analytics?: { entityId: string };
className?: string;
};
function formatTime(value: number) {
if (!Number.isFinite(value) || value < 0) return "0:00";
const minutes = Math.floor(value / 60);
const seconds = Math.floor(value % 60).toString().padStart(2, "0");
return `${minutes}:${seconds}`;
}
export default function AudioPlayer({ src, title, locale, downloadable = false, analytics, className }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const hasTrackedPlay = useRef(false);
const labels = locale === "ar"
? { play: "تشغيل", pause: "إيقاف مؤقت", back: "تأخير 10 ثوانٍ", forward: "تقديم 10 ثوانٍ", seek: "موضع التشغيل", volume: "مستوى الصوت", download: "تحميل الصوت" }
: { play: "Play", pause: "Pause", back: "Back 10 seconds", forward: "Forward 10 seconds", seek: "Playback position", volume: "Volume", download: "Download audio" };
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onLoadedMetadata = () => setDuration(audio.duration);
const onTimeUpdate = () => setCurrentTime(audio.currentTime);
const onEnded = () => setIsPlaying(false);
audio.addEventListener("loadedmetadata", onLoadedMetadata);
audio.addEventListener("timeupdate", onTimeUpdate);
audio.addEventListener("ended", onEnded);
audio.volume = volume;
return () => {
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
audio.removeEventListener("timeupdate", onTimeUpdate);
audio.removeEventListener("ended", onEnded);
};
}, [volume]);
const togglePlayback = async () => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
await audio.play();
setIsPlaying(true);
if (analytics && !hasTrackedPlay.current) {
hasTrackedPlay.current = true;
trackClientEvent({ type: "MELODY_PLAY", entityId: analytics.entityId });
}
} else {
audio.pause();
setIsPlaying(false);
}
};
const seekBy = (seconds: number) => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = Math.max(0, Math.min(audio.duration || 0, audio.currentTime + seconds));
setCurrentTime(audio.currentTime);
};
const updateTime = (value: number) => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = value;
setCurrentTime(value);
};
const updateVolume = (value: number) => {
setVolume(value);
if (audioRef.current) audioRef.current.volume = value;
};
return (
<div className={cn("rounded-xl border border-border bg-card p-4", className)}>
<audio ref={audioRef} src={src} preload="metadata" />
<div className="flex items-center gap-2">
<button type="button" onClick={togglePlayback} aria-label={isPlaying ? labels.pause : labels.play} className="inline-flex h-10 min-w-10 items-center justify-center rounded-full bg-primary px-3 text-sm font-medium text-primary-foreground hover:opacity-90">
{isPlaying ? "Ⅱ" : "▶"}
</button>
<button type="button" onClick={() => seekBy(-10)} aria-label={labels.back} className="rounded-md border border-border px-2 py-2 text-xs hover:bg-accent">10</button>
<button type="button" onClick={() => seekBy(10)} aria-label={labels.forward} className="rounded-md border border-border px-2 py-2 text-xs hover:bg-accent">+10</button>
<span className="ms-auto text-xs tabular-nums text-muted-foreground">{formatTime(currentTime)} / {formatTime(duration)}</span>
</div>
<input type="range" min={0} max={duration || 0} step="any" value={Math.min(currentTime, duration || 0)} onChange={(event) => updateTime(event.target.valueAsNumber)} aria-label={labels.seek} className="mt-4 w-full accent-primary" />
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
<span aria-hidden="true">🔊</span>
<input type="range" min={0} max={1} step="0.05" value={volume} onChange={(event) => updateVolume(event.target.valueAsNumber)} aria-label={labels.volume} className="w-28 accent-primary" />
{downloadable ? <a href={src} download className="ms-auto font-medium text-brand-2 hover:underline">{labels.download}</a> : null}
</div>
<p className="sr-only">{title}</p>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { submitContactMessage } from "@/app/[locale]/contact/actions";
type ContactFormProps = {
labels: {
name: string;
email: string;
message: string;
submit: string;
sending: string;
success: string;
savedWarning: string;
invalid: string;
rateLimit: string;
};
};
export default function ContactForm({ labels }: ContactFormProps) {
const [status, setStatus] = useState<{ type: "success" | "warning" | "error"; message: string } | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus(null);
setIsSubmitting(true);
const form = event.currentTarget;
const result = await submitContactMessage(Object.fromEntries(new FormData(form).entries()));
setIsSubmitting(false);
if (result.error) {
setStatus({ type: "error", message: result.error === "rate-limit" ? labels.rateLimit : labels.invalid });
return;
}
form.reset();
setStatus({ type: result.warning ? "warning" : "success", message: result.warning ? labels.savedWarning : labels.success });
}
return (
<form onSubmit={handleSubmit} className="card contact-form space-y-4" noValidate>
<div className="grid gap-4 sm:grid-cols-2">
<label className="space-y-2"><span>{labels.name}</span><input name="name" required maxLength={100} autoComplete="name" /></label>
<label className="space-y-2"><span>{labels.email}</span><input name="email" type="email" required maxLength={254} autoComplete="email" /></label>
</div>
<label className="space-y-2"><span>{labels.message}</span><textarea name="message" required minLength={10} maxLength={5000} rows={6} /></label>
<label aria-hidden="true" className="absolute -left-[9999px] h-px w-px overflow-hidden"><span>Website</span><input name="website" tabIndex={-1} autoComplete="off" /></label>
<button type="submit" disabled={isSubmitting}>{isSubmitting ? labels.sending : labels.submit}</button>
{status ? <p role={status.type === "error" ? "alert" : "status"} className={status.type === "error" ? "text-sm text-destructive" : "text-sm text-muted-foreground"}>{status.message}</p> : null}
</form>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { LegalDocument } from "@/lib/legal-content";
export default function LegalDocument({ document }: { document: LegalDocument }) {
return (
<article className="mx-auto w-full max-w-4xl space-y-10 px-4 py-10 sm:px-6 lg:px-8">
<header className="space-y-4 border-b border-border pb-8">
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">{document.title}</h1>
<p className="max-w-3xl text-lg leading-8 text-muted-foreground">{document.intro}</p>
<p className="text-sm text-muted-foreground">{document.updatedLabel}</p>
</header>
<div className="space-y-8">
{document.sections.map((section) => (
<section key={section.heading} className="space-y-3">
<h2 className="text-2xl font-semibold tracking-tight">{section.heading}</h2>
{section.paragraphs?.map((paragraph) => <p key={paragraph} className="whitespace-pre-line leading-8 text-foreground">{paragraph}</p>)}
{section.list ? <ul className="list-inside list-disc space-y-2 leading-8 text-foreground">{section.list.map((item) => <li key={item}>{item}</li>)}</ul> : null}
</section>
))}
</div>
</article>
);
}
+36
View File
@@ -0,0 +1,36 @@
import Image from "next/image";
import Link from "next/link";
import type { Locale } from "@/lib/i18n";
import { getMelodyPath } from "@/lib/melody-content";
import AudioPlayer from "@/components/public/audio-player";
type MelodyCardProps = {
locale: Locale;
melody: {
slug: string;
id: string;
titleAr: string;
titleEn: string;
descAr: string | null;
descEn: string | null;
audioFile: string;
coverImage: string | null;
isDownloadable: boolean;
category: { nameAr: string; nameEn: string };
};
};
export default function MelodyCard({ locale, melody }: MelodyCardProps) {
const title = locale === "ar" ? melody.titleAr : melody.titleEn;
const description = locale === "ar" ? melody.descAr : melody.descEn;
const category = locale === "ar" ? melody.category.nameAr : melody.category.nameEn;
return (
<article className="overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
{melody.coverImage ? <Link href={getMelodyPath(locale, melody.slug)} className="relative block aspect-[16/9] bg-muted"><Image src={melody.coverImage} alt={title} fill unoptimized sizes="(max-width: 768px) 100vw, 50vw" className="object-cover" /></Link> : null}
<div className="space-y-4 p-5">
<div><p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-2">{category}</p><h2 className="mt-2 text-xl font-semibold"><Link href={getMelodyPath(locale, melody.slug)} className="hover:underline">{title}</Link></h2>{description ? <p className="mt-2 line-clamp-2 text-sm leading-6 text-muted-foreground">{description}</p> : null}</div>
<AudioPlayer src={melody.audioFile} title={title} locale={locale} downloadable={melody.isDownloadable} analytics={{ entityId: melody.id }} />
</div>
</article>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Image from "next/image";
import Link from "next/link";
import type { Locale } from "@/lib/i18n";
import { getMelodyDirectoryCopy, getMelodyPath } from "@/lib/melody-content";
import AudioPlayer from "@/components/public/audio-player";
export default function MelodyDetail({ locale, melody }: { locale: Locale; melody: { id: string; slug: string; titleAr: string; titleEn: string; descAr: string | null; descEn: string | null; audioFile: string; coverImage: string | null; isDownloadable: boolean; category: { nameAr: string; nameEn: string } } }) {
const title = locale === "ar" ? melody.titleAr : melody.titleEn;
const description = locale === "ar" ? melody.descAr : melody.descEn;
const category = locale === "ar" ? melody.category.nameAr : melody.category.nameEn;
const copy = getMelodyDirectoryCopy(locale);
return (
<article className="mx-auto w-full max-w-5xl space-y-10 px-4 py-10 sm:px-6 lg:px-8">
<Link href={getMelodyPath(locale)} className="text-sm font-medium text-brand-2 hover:underline"> {copy.back}</Link>
<header className="max-w-3xl space-y-4"><p className="text-sm font-semibold uppercase tracking-[0.18em] text-brand-2">{category}</p><h1 className="text-4xl font-semibold tracking-tight sm:text-6xl">{title}</h1>{description ? <p className="text-xl leading-8 text-muted-foreground">{description}</p> : null}</header>
{melody.coverImage ? <div className="relative aspect-[16/7] overflow-hidden rounded-2xl border border-border bg-muted"><Image src={melody.coverImage} alt={title} fill unoptimized sizes="100vw" className="object-cover" priority /></div> : null}
<div className="max-w-2xl"><AudioPlayer src={melody.audioFile} title={title} locale={locale} downloadable={melody.isDownloadable} analytics={{ entityId: melody.id }} /></div>
</article>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import type { Locale } from "@/lib/i18n";
import { getMelodyDirectoryCopy, getMelodyPath } from "@/lib/melody-content";
import { getMelodyCategories, getPublishedMelodies } from "@/lib/melody-queries";
import MelodyCard from "@/components/public/melody-card";
export default async function MelodyDirectory({ locale, categorySlug }: { locale: Locale; categorySlug?: string }) {
const [melodies, categories] = await Promise.all([getPublishedMelodies(categorySlug), getMelodyCategories()]);
const copy = getMelodyDirectoryCopy(locale);
const allHref = getMelodyPath(locale);
return (
<section className="mx-auto w-full max-w-6xl space-y-8 px-4 py-10 sm:px-6 lg:px-8">
<header className="max-w-3xl space-y-3"><p className="text-sm font-semibold uppercase tracking-[0.18em] text-brand-2">{copy.eyebrow}</p><h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">{copy.title}</h1><p className="text-lg leading-8 text-muted-foreground">{copy.description}</p></header>
{categories.length > 0 ? <nav aria-label={copy.filterLabel} className="flex flex-wrap gap-2"><Link href={allHref} className={!categorySlug ? "rounded-full bg-primary px-4 py-2 text-sm text-primary-foreground" : "rounded-full border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"}>{copy.all}</Link>{categories.map((category) => <Link key={category.id} href={`${allHref}?category=${category.slug}`} className={categorySlug === category.slug ? "rounded-full bg-primary px-4 py-2 text-sm text-primary-foreground" : "rounded-full border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"}>{locale === "ar" ? category.nameAr : category.nameEn}</Link>)}</nav> : null}
{melodies.length === 0 ? <div className="rounded-xl border border-dashed border-border bg-card p-10 text-center text-muted-foreground">{copy.empty}</div> : <div className="grid gap-6 md:grid-cols-2">{melodies.map((melody) => <MelodyCard key={melody.id} locale={locale} melody={melody} />)}</div>}
</section>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Image from "next/image";
import Link from "next/link";
import type { ProjectType } from "@prisma/client";
import type { Locale } from "@/lib/i18n";
import { getProjectPath } from "@/lib/project-content";
type ProjectCardProps = {
locale: Locale;
type: ProjectType;
project: {
slug: string;
titleAr: string;
titleEn: string;
summaryAr: string | null;
summaryEn: string | null;
coverImage: string | null;
category: { nameAr: string; nameEn: string } | null;
};
};
export default function ProjectCard({ locale, type, project }: ProjectCardProps) {
const title = locale === "ar" ? project.titleAr : project.titleEn;
const summary = locale === "ar" ? project.summaryAr : project.summaryEn;
const category = project.category ? (locale === "ar" ? project.category.nameAr : project.category.nameEn) : null;
return (
<article className="group overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-transform hover:-translate-y-1">
<Link href={getProjectPath(locale, type, project.slug)} className="block">
<div className="relative aspect-[16/10] overflow-hidden bg-muted">
{project.coverImage ? (
<Image
src={project.coverImage}
alt={title}
fill
unoptimized
sizes="(max-width: 768px) 100vw, 33vw"
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">{title}</div>
)}
</div>
<div className="space-y-2 p-5">
{category ? <p className="text-xs font-semibold uppercase tracking-[0.14em] text-brand-2">{category}</p> : null}
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
{summary ? <p className="text-sm leading-6 text-muted-foreground">{summary}</p> : null}
</div>
</Link>
</article>
);
}
+136
View File
@@ -0,0 +1,136 @@
import Image from "next/image";
import Link from "next/link";
import type { ProjectType } from "@prisma/client";
import type { Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy, getProjectPath } from "@/lib/project-content";
import AnalyticsTracker from "@/components/public/analytics-tracker";
import TrackedLink from "@/components/public/tracked-link";
type ProjectDetailProps = {
locale: Locale;
type: ProjectType;
project: {
id: string;
slug: string;
titleAr: string;
titleEn: string;
descAr: string | null;
descEn: string | null;
summaryAr: string | null;
summaryEn: string | null;
coverImage: string | null;
images: string[];
technologies: string[];
externalUrl: string | null;
repoUrl: string | null;
platform: string | null;
appStoreUrl: string | null;
testflightUrl: string | null;
appVersion: string | null;
supportUrl: string | null;
appPrivacyUrl: string | null;
category: { nameAr: string; nameEn: string } | null;
};
};
export default function ProjectDetail({ locale, type, project }: ProjectDetailProps) {
const title = locale === "ar" ? project.titleAr : project.titleEn;
const summary = locale === "ar" ? project.summaryAr : project.summaryEn;
const description = locale === "ar" ? project.descAr : project.descEn;
const category = project.category ? (locale === "ar" ? project.category.nameAr : project.category.nameEn) : null;
const copy = getProjectDirectoryCopy(locale, type);
const backLabel = locale === "ar" ? `العودة إلى ${copy.eyebrow}` : `Back to ${copy.eyebrow}`;
const isApp = type === "APP";
return (
<article className="mx-auto w-full max-w-6xl space-y-10 px-4 py-10 sm:px-6 lg:px-8">
<AnalyticsTracker event="PROJECT_OPEN" entityId={project.id} />
<Link href={getProjectPath(locale, type)} className="text-sm font-medium text-brand-2 hover:underline">
{backLabel}
</Link>
<header className="max-w-3xl space-y-4">
{category ? <p className="text-sm font-semibold uppercase tracking-[0.18em] text-brand-2">{category}</p> : null}
<h1 className="text-4xl font-semibold tracking-tight sm:text-6xl">{title}</h1>
{summary ? <p className="text-xl leading-8 text-muted-foreground">{summary}</p> : null}
</header>
{project.coverImage ? (
<div className="relative aspect-[16/8] overflow-hidden rounded-2xl border border-border bg-muted">
<Image src={project.coverImage} alt={title} fill unoptimized sizes="100vw" className="object-cover" priority />
</div>
) : null}
<div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="space-y-8">
{description ? <p className="whitespace-pre-line text-lg leading-8 text-foreground">{description}</p> : null}
{project.images.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{project.images.map((image, index) => (
<div key={image} className="relative aspect-[4/3] overflow-hidden rounded-xl border border-border bg-muted">
<Image src={image} alt={`${title} ${index + 1}`} fill unoptimized sizes="(max-width: 640px) 100vw, 50vw" className="object-cover" />
</div>
))}
</div>
) : null}
</div>
<aside className="space-y-6 rounded-xl border border-border bg-card p-5">
{project.technologies.length > 0 ? (
<div>
<h2 className="text-sm font-semibold">{locale === "ar" ? "التقنيات" : "Technologies"}</h2>
<div className="mt-3 flex flex-wrap gap-2">
{project.technologies.map((technology) => (
<span key={technology} className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground">
{technology}
</span>
))}
</div>
</div>
) : null}
{isApp ? (
<div className="space-y-3 border-t border-border pt-5 text-sm">
<h2 className="font-semibold">{locale === "ar" ? "تفاصيل التطبيق" : "App details"}</h2>
{project.platform ? <p className="text-muted-foreground">{project.platform}</p> : null}
{project.appVersion ? <p className="text-muted-foreground">{locale === "ar" ? "الإصدار" : "Version"}: {project.appVersion}</p> : null}
</div>
) : null}
<div className="flex flex-col gap-3 text-sm">
{project.externalUrl ? (
<TrackedLink href={project.externalUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "فتح الرابط" : "Open live link"}
</TrackedLink>
) : null}
{project.repoUrl ? (
<TrackedLink href={project.repoUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "مستودع GitHub" : "Open repository"}
</TrackedLink>
) : null}
{project.appStoreUrl ? (
<TrackedLink href={project.appStoreUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "فتح App Store" : "Open App Store"}
</TrackedLink>
) : null}
{project.testflightUrl ? (
<TrackedLink href={project.testflightUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "فتح TestFlight" : "Open TestFlight"}
</TrackedLink>
) : null}
{project.supportUrl ? (
<TrackedLink href={project.supportUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "رابط الدعم" : "Support link"}
</TrackedLink>
) : null}
{project.appPrivacyUrl ? (
<TrackedLink href={project.appPrivacyUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "سياسة الخصوصية" : "Privacy policy"}
</TrackedLink>
) : null}
</div>
</aside>
</div>
</article>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import type { ProjectType } from "@prisma/client";
import type { Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy, getProjectPath } from "@/lib/project-content";
import { getProjectCategories, getPublishedProjects } from "@/lib/project-queries";
import ProjectCard from "@/components/public/project-card";
type ProjectDirectoryProps = {
locale: Locale;
type: ProjectType;
categorySlug?: string;
};
export default async function ProjectDirectory({ locale, type, categorySlug }: ProjectDirectoryProps) {
const [projects, categories] = await Promise.all([getPublishedProjects(type, categorySlug), getProjectCategories()]);
const copy = getProjectDirectoryCopy(locale, type);
const allHref = getProjectPath(locale, type);
return (
<section className="mx-auto w-full max-w-6xl space-y-8 px-4 py-10 sm:px-6 lg:px-8">
<header className="max-w-3xl space-y-3">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-brand-2">{copy.eyebrow}</p>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">{copy.title}</h1>
<p className="text-lg leading-8 text-muted-foreground">{copy.description}</p>
</header>
{categories.length > 0 ? (
<nav aria-label={locale === "ar" ? "فلترة التصنيفات" : "Filter by category"} className="flex flex-wrap gap-2">
<Link
href={allHref}
className={!categorySlug ? "rounded-full bg-primary px-4 py-2 text-sm text-primary-foreground" : "rounded-full border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"}
>
{locale === "ar" ? "الكل" : "All"}
</Link>
{categories.map((category) => (
<Link
key={category.id}
href={`${allHref}?category=${category.slug}`}
className={categorySlug === category.slug ? "rounded-full bg-primary px-4 py-2 text-sm text-primary-foreground" : "rounded-full border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"}
>
{locale === "ar" ? category.nameAr : category.nameEn}
</Link>
))}
</nav>
) : null}
{projects.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-card p-10 text-center text-muted-foreground">{copy.empty}</div>
) : (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} locale={locale} type={type} project={project} />
))}
</div>
)}
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import type { AnchorHTMLAttributes, ReactNode } from "react";
import { trackClientEvent } from "@/lib/analytics-client";
type TrackedLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
entityId: string;
children: ReactNode;
};
export default function TrackedLink({ entityId, onClick, children, ...props }: TrackedLinkProps) {
return <a {...props} onClick={(event) => { trackClientEvent({ type: "PROJECT_LINK_CLICK", entityId }); onClick?.(event); }}>{children}</a>;
}
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:opacity-90",
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:opacity-90",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-card border border-border bg-card text-card-foreground shadow-sm", className)} {...props} />
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />,
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+89
View File
@@ -0,0 +1,89 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed start-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-card border border-border bg-background p-6 text-foreground shadow-theme duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-start", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+134
View File
@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ ...props }: ControllerProps<TFieldValues, TName>) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext.name) {
throw new Error("useFormField must be used within <FormField>");
}
if (!itemContext.id) {
throw new Error("useFormField must be used within <FormItem>");
}
return {
id: itemContext.id,
name: fieldContext.name,
formItemId: `${itemContext.id}-form-item`,
formDescriptionId: `${itemContext.id}-form-item-description`,
formMessageId: `${itemContext.id}-form-item-message`,
...fieldState,
};
};
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />;
},
);
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error.message ?? "") : children;
if (!body) {
return null;
}
return (
<p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = "FormMessage";
export { useFormField, Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage };
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+128
View File
@@ -0,0 +1,128 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton ref={ref} className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton ref={ref} className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 ps-8 pe-2 text-sm font-semibold", className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute start-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+18
View File
@@ -10,9 +10,18 @@ const ar: Dictionary = {
themeDark: "داكن", themeDark: "داكن",
nav: { nav: {
home: "الرئيسية", home: "الرئيسية",
projects: "المشاريع",
apps: "التطبيقات",
melodies: "الألحان",
about: "من أنا", about: "من أنا",
contact: "تواصل", contact: "تواصل",
}, },
legal: {
label: "الصفحات الرسمية",
privacy: "الخصوصية",
terms: "شروط الاستخدام",
impressum: "Impressum · بيانات المزوّد",
},
footerRights: "{year} جميع الحقوق محفوظة", footerRights: "{year} جميع الحقوق محفوظة",
variants: { variants: {
comingSoon: { comingSoon: {
@@ -92,6 +101,15 @@ const ar: Dictionary = {
channelsTitle: "روابطك المباشرة", channelsTitle: "روابطك المباشرة",
channelCta: "فتح الرابط", channelCta: "فتح الرابط",
channelFallback: "ستظهر القناة هنا بعد إضافة الرابط الفعلي", channelFallback: "ستظهر القناة هنا بعد إضافة الرابط الفعلي",
formName: "الاسم",
formEmail: "البريد الإلكتروني",
formMessage: "الرسالة",
formSubmit: "إرسال الرسالة",
formSending: "جارٍ الإرسال…",
formSuccess: "تم استلام رسالتك، وسأعود إليك قريباً.",
formSavedWarning: "تم حفظ رسالتك، لكن تعذّر إرسال إشعار البريد حالياً.",
formInvalid: "تحقق من الاسم والبريد والرسالة وحاول مجدداً.",
formRateLimit: "تم تجاوز الحد المؤقت للإرسال. حاول بعد قليل.",
channels: [ channels: [
{ key: "email", name: "البريد الإلكتروني", hint: "للتواصل المهني المباشر والاستفسارات" }, { key: "email", name: "البريد الإلكتروني", hint: "للتواصل المهني المباشر والاستفسارات" },
{ key: "linkedin", name: "لينكدإن", hint: "للعلاقات المهنية والعرض السريع للخبرات" }, { key: "linkedin", name: "لينكدإن", hint: "للعلاقات المهنية والعرض السريع للخبرات" },
+18
View File
@@ -10,9 +10,18 @@ const en: Dictionary = {
themeDark: "Dark", themeDark: "Dark",
nav: { nav: {
home: "Home", home: "Home",
projects: "Projects",
apps: "Apps",
melodies: "Melodies",
about: "About", about: "About",
contact: "Contact", contact: "Contact",
}, },
legal: {
label: "Legal pages",
privacy: "Privacy",
terms: "Terms",
impressum: "Legal notice",
},
footerRights: "{year} All rights reserved", footerRights: "{year} All rights reserved",
variants: { variants: {
comingSoon: { comingSoon: {
@@ -92,6 +101,15 @@ const en: Dictionary = {
channelsTitle: "Direct contact links", channelsTitle: "Direct contact links",
channelCta: "Open link", channelCta: "Open link",
channelFallback: "This channel will appear once the real link is added", channelFallback: "This channel will appear once the real link is added",
formName: "Name",
formEmail: "Email",
formMessage: "Message",
formSubmit: "Send message",
formSending: "Sending…",
formSuccess: "Your message was received. Ill get back to you soon.",
formSavedWarning: "Your message was saved, but the email notification could not be sent right now.",
formInvalid: "Check your name, email, and message, then try again.",
formRateLimit: "The temporary sending limit was reached. Please try again later.",
channels: [ channels: [
{ key: "email", name: "Email", hint: "For direct professional contact and project inquiries" }, { key: "email", name: "Email", hint: "For direct professional contact and project inquiries" },
{ key: "linkedin", name: "LinkedIn", hint: "For professional background and networking" }, { key: "linkedin", name: "LinkedIn", hint: "For professional background and networking" },
+18
View File
@@ -17,9 +17,18 @@ export type CommonContent = {
themeDark: string; themeDark: string;
nav: { nav: {
home: string; home: string;
projects: string;
apps: string;
melodies: string;
about: string; about: string;
contact: string; contact: string;
}; };
legal: {
label: string;
privacy: string;
terms: string;
impressum: string;
};
footerRights: string; footerRights: string;
variants: ModeVariants<CommonVariantContent>; variants: ModeVariants<CommonVariantContent>;
}; };
@@ -66,6 +75,15 @@ export type ContactContent = {
channelsTitle: string; channelsTitle: string;
channelCta: string; channelCta: string;
channelFallback: string; channelFallback: string;
formName: string;
formEmail: string;
formMessage: string;
formSubmit: string;
formSending: string;
formSuccess: string;
formSavedWarning: string;
formInvalid: string;
formRateLimit: string;
channels: ContactChannelContent[]; channels: ContactChannelContent[];
}; };
+51 -5
View File
@@ -4,20 +4,25 @@ services:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://example.com} NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://diyaa.de}
NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon} NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon}
NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-} NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-} NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
NEXT_PUBLIC_GITHUB_URL: ${NEXT_PUBLIC_GITHUB_URL:-} NEXT_PUBLIC_GITHUB_URL: ${NEXT_PUBLIC_GITHUB_URL:-}
container_name: ${CONTAINER_NAME:-diyaa} container_name: ${CONTAINER_NAME:-diyaa-website}
ports: depends_on:
- "${APP_PORT:-30002}:3000" postgres:
condition: service_healthy
environment: environment:
NODE_ENV: ${NODE_ENV:-production} NODE_ENV: ${NODE_ENV:-production}
PORT: 3000 PORT: 3000
HOSTNAME: 0.0.0.0 HOSTNAME: 0.0.0.0
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-1} NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-1}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://example.com} DATABASE_URL: postgresql://${POSTGRES_USER:-diyaa}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-diyaa}?schema=public
UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads}
AUTH_SECRET: ${AUTH_SECRET:-}
AUTH_URL: ${AUTH_URL:-https://diyaa.de}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://diyaa.de}
NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon} NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon}
NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-} NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-} NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
@@ -29,3 +34,44 @@ services:
retries: 5 retries: 5
start_period: 20s start_period: 20s
restart: unless-stopped restart: unless-stopped
volumes:
- uploads_data:/app/uploads
networks:
- traefik-public
- app-internal
labels:
- "traefik.enable=true"
- "traefik.http.routers.diyaa-website.rule=Host(`diyaa.de`)"
- "traefik.http.routers.diyaa-website.entrypoints=websecure"
- "traefik.http.routers.diyaa-website.tls.certresolver=le"
- "traefik.http.services.diyaa-website.loadbalancer.server.port=3000"
postgres:
image: postgres:16-alpine
container_name: ${POSTGRES_CONTAINER_NAME:-diyaa-postgres}
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-diyaa}
POSTGRES_USER: ${POSTGRES_USER:-diyaa}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
ports:
- "127.0.0.1:${POSTGRES_PORT:-55432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-diyaa} -d ${POSTGRES_DB:-diyaa}"]
interval: 5s
timeout: 5s
retries: 10
networks:
- app-internal
networks:
traefik-public:
external: true
app-internal:
driver: bridge
volumes:
postgres_data:
uploads_data:
+249
View File
@@ -0,0 +1,249 @@
# 🏛️ المعمارية والاتفاقيات — diyaa.de
> هاد الملف بيحدّد **الشكل المستهدف** للمشروع. أي feature جديد لازم يتبع هالاتفاقيات.
---
## 1) البنية العامة
الموقع بينقسم لـ **واجهة عامة (public)** + **لوحة تحكم (admin)** + **API/Server Actions** + **قاعدة بيانات**.
```
[ الزوّار ] ──> صفحات عامة (app/[locale]/…) ──┐
├──> Server Actions / Route Handlers ──> Prisma ──> PostgreSQL
[ المالك ] ──> لوحة التحكم (app/admin/…) ─────┘ │
ملفات مرفوعة (volume على الـ VPS)
```
---
## 2) أقسام الموقع (من رؤية المالك)
> كل هالأقسام (عدا الألحان) = صفوف بجدول `Project` واحد تتفلتر بحقل `type`. الفصل بالـ UI فقط.
1. **الأعمال / المعرض (Portfolio)**`type=PORTFOLIO` بتصنيفات (شعارات، بروشورات، متاجر، أدوات ذكاء اصطناعي…).
2. **التطبيقات (Apps)**`type=APP` تطبيقات Swift، مع **سكرين شوتس** + حقول iOS (App Store، TestFlight، الإصدار، رابط الدعم، سياسة خصوصية التطبيق).
3. **المواقع (Websites)**`type=WEBSITE` مواقع Next.js/WordPress، مع سكرين شوتس ورابط حي.
4. **التصاميم (Designs)**`type=DESIGN` (أو ضمن PORTFOLIO) — شعارات، هويات بصرية.
5. **الألحان (Melodies)** — جدول `Melody` منفصل، عرض وتشغيل بتصنيفات (عربية، شرقية…) + تحميل اختياري.
6. **صفحات رسمية** — سياسة الخصوصية، الشروط، **Impressum** (إلزامي قانونياً بألمانيا).
7. **تواصل** — موجود، بنربطه بإرسال إيميل عبر SMTP + **Honeypot + Rate limiting**.
8. **لوحة تحكم** — إضافة/تعديل/حذف كل ما سبق + التصنيفات.
---
## 3) مخطط قاعدة البيانات (Prisma) — **الخيار A المعتمد**
> **القرار (2026-08-05):** جدول **`Project` موحّد** لكل الأعمال والتطبيقات والمواقع والتصاميم (يتميّزوا بحقل `type`)، و**`Melody` منفصل** (طبيعته صوتية). التقنيات والسكرين شوتس كـ `String[]` (بدون جداول join)؛ نضيف جداول `Technology`/`Media` منفصلة لاحقاً فقط لو لزم فلترة بالتقنية أو إدارة alt-text متقدمة. التصنيف علاقة **واحد-لواحد** (مش many-to-many).
>
> ⚠️ هاد **يستبدل** مخطط `Work/App/Melody` القديم يلي انبنى بـ T-03/T-06. الاستبدال آمن لأنو ما في داتا إنتاجية بعد. شوف مهمة الـ refactor بـ `TODO.md`.
```prisma
enum ProjectType {
PORTFOLIO // شعار، بروشور، هوية بصرية… (سابقاً "أعمال/تصاميم")
APP // تطبيق Swift/iOS
WEBSITE // موقع Next.js أو WordPress
DESIGN // تصميم منفصل لو حبيت تفصله عن PORTFOLIO
}
enum CategoryKind {
PROJECT // تصنيفات المشاريع (شعارات، بروشورات، مواقع، متاجر، أدوات AI…)
MELODY // تصنيفات الألحان (عربي، شرقي…)
}
enum PublishStatus {
DRAFT
PUBLISHED
ARCHIVED
}
model Category {
id String @id @default(cuid())
kind CategoryKind
slug String @unique
nameAr String
nameEn String
order Int @default(0)
projects Project[]
melodies Melody[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Project { // جدول موحّد: أعمال + تطبيقات + مواقع + تصاميم
id String @id @default(cuid())
type ProjectType
slug String @unique
titleAr String
titleEn String
summaryAr String?
summaryEn String?
descAr String?
descEn String?
coverImage String? // مسار الغلاف
images String[] // معرض صور / سكرين شوتس
technologies String[] // التقنيات المستخدمة (بسيط، بدون جدول منفصل)
externalUrl String? // رابط المشروع/الموقع الحي
repoUrl String? // GitHub اختياري
// ── حقول خاصة بالتطبيقات (تبقى فاضية لغير APP) ──
platform String? // "iOS/Swift" | "Next.js" | "WordPress"…
appStoreUrl String?
testflightUrl String?
appVersion String?
supportUrl String?
appPrivacyUrl String?
// ── حالة وترتيب ──
status PublishStatus @default(DRAFT)
isFeatured Boolean @default(false)
sortOrder Int @default(0)
publishedAt DateTime?
categoryId String?
category Category? @relation(fields: [categoryId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([type, status])
}
model Melody { // لحن (منفصل بسبب طبيعته الصوتية)
id String @id @default(cuid())
slug String @unique
titleAr String
titleEn String
descAr String?
descEn String?
audioFile String // مسار ملف الصوت
coverImage String? // artwork
durationSec Int?
isDownloadable Boolean @default(false) // سماح/منع التحميل
status PublishStatus @default(DRAFT)
isFeatured Boolean @default(false)
sortOrder Int @default(0)
categoryId String
category Category @relation(fields: [categoryId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model ContactMessage { // رسائل نموذج التواصل
id String @id @default(cuid())
name String
email String
message String
createdAt DateTime @default(now())
}
enum AnalyticsEventType {
PAGE_VIEW
PROJECT_OPEN
MELODY_PLAY
PROJECT_LINK_CLICK
CONTACT_SUBMITTED
}
model AnalyticsEvent { // عدّادات داخلية بدون خدمة خارجية
id String @id @default(cuid())
type AnalyticsEventType
path String?
projectId String?
melodyId String?
createdAt DateTime @default(now())
}
model User { // مستخدم لوحة التحكم (أنت)
id String @id @default(cuid())
email String @unique
password String // hashed
createdAt DateTime @default(now())
}
// (مؤجّل) إعدادات الموقع من الأدمن — نضيفه عند الحاجة
// model SiteSetting { id String @id key String @unique value String }
```
---
## 4) اتفاقيات المجلدات (الشكل المستهدف)
```
diyaa.de/
├── app/
│ ├── [locale]/ # الموقع العام
│ │ ├── page.tsx # الرئيسية
│ │ ├── work/ # المعرض (PORTFOLIO) + [slug]
│ │ ├── apps/ # التطبيقات (APP) + [slug]
│ │ ├── websites/ # المواقع (WEBSITE) + [slug]
│ │ ├── designs/ # التصاميم (DESIGN) + [slug]
│ │ ├── melodies/ # الألحان + [slug]
│ │ ├── legal/ # privacy, terms, impressum
│ │ ├── about/ · contact/
│ ├── maintenance/ · coming-soon/ # صفحات الأوضاع
│ ├── admin/ # 🔒 لوحة التحكم (محميّة بـ Auth)
│ │ ├── projects/ · melodies/ · categories/ · settings/
│ │ └── layout.tsx
│ ├── api/ # Route Handlers (health, upload, …)
│ └── globals.css
├── components/
│ ├── ui/ # 🧩 shadcn/ui (مكوّنات أساسية)
│ ├── public/ # مكوّنات الواجهة العامة
│ ├── admin/ # مكوّنات لوحة التحكم
│ └── shared/ # مشتركة
├── lib/
│ ├── db.ts # Prisma client (singleton)
│ ├── auth.ts # إعداد Auth.js
│ ├── email.ts # Nodemailer + SMTP
│ ├── upload.ts # منطق رفع الملفات
│ ├── validations/ # مخططات Zod
│ ├── i18n.ts · site.ts · metadata.ts
├── prisma/
│ └── schema.prisma # 🎯 مصدر الحقيقة لبنية الداتا
├── content/ # قواميس الترجمة الثابتة (ar/en)
├── docs/ # 📚 التوثيق
└── (Docker, Makefile, …)
```
---
## 5) اتفاقيات المكوّنات (Components)
- **كل شي كمكوّن قابل لإعادة الاستخدام.** لا تكرّر UI.
- مكوّنات `components/ui/` = shadcn الأساسية (Button, Card, Dialog, Input…). لا تعدّل سلوكها الأساسي، ابنِ فوقها.
- مكوّنات الأقسام (ProjectCard مشترك لكل الأنواع + MelodyCard + AudioPlayer) بتعيش بـ `components/public/`.
- **Server Components افتراضياً**؛ استخدم `"use client"` فقط عند الحاجة للتفاعل (مشغّل صوت، فورمات، ثيم).
- كل مكوّن عام لازم يدعم **العربي والإنجليزي** ويحترم اتجاه RTL/LTR.
- الأنماط عبر **Tailwind utilities** + متغيّرات الثيم؛ لا CSS خام جديد (نحافظ على متغيّرات الثيم الموجودة لكن عبر إعداد Tailwind).
## 6) اتفاقيات Server Actions / API
- عمليات لوحة التحكم (إنشاء/تعديل/حذف) → **Server Actions** بـ فاليديشن Zod.
- كل action بيتحقق من الجلسة (Auth) قبل أي كتابة.
- endpoints العامة (رفع، health، ربما RSS للألحان) → `app/api/`.
- كل مدخلات المستخدم تمرّ بـ Zod schema من `lib/validations/`.
- **نموذج التواصل:** يتحمّى بـ **Honeypot** (حقل مخفي) + **Rate limiting** (تحديد عدد الطلبات) لمنع السبام.
- **رفع الملفات:** تحقّق من نوع الملف وحجمه، أسماء ملفات آمنة، `altText` للصور، و**حذف الملف الفعلي من الـ volume عند حذف المحتوى**.
## 7) الوسائط والملفات
- الملفات المرفوعة تُخزّن على **volume** بالـ VPS (مش داخل صورة Docker).
- الصور تُعالَج بـ `sharp` (تصغير + webp).
- مسارات الملفات تُخزّن كـ نصوص بالداتابيس (مش الملف نفسه).
- الرفع يتم عبر `app/api/upload/` بعد التحقق من جلسة الأدمن، والملفات تُقدّم عبر `app/api/uploads/`.
- مسار التخزين قابل للضبط عبر `UPLOAD_DIR`، والـ Docker volume الافتراضي هو `/app/uploads`.
## 8) الأوضاع (Site modes)
- `NEXT_PUBLIC_SITE_MODE` بيقبل **ثلاث قيم**: `full` · `maintenance` · `coming-soon`.
- `coming-soon`: يعرض `ComingSoonPanel` ويخفي الأقسام (موجود حالياً).
- `maintenance`: يعرض صفحة صيانة (جديد — T-14).
- `full`: الموقع الكامل.
-**T-17 منجز:** `.env.example` يستخدم `full` بدل `live`، و`lib/site.ts` يعتمد `coming-soon | maintenance | full`. واجهة الصيانة الفعلية والاستثناء المرئي للأدمن ضمن T-14.
- لوحة التحكم بتشتغل **بغضّ النظر عن الوضع** (محميّة بالـ Auth) عشان تحضّر المحتوى قبل الإطلاق.
---
## 9) البيئة والنشر
- بيضاف خدمة **postgres** لـ `docker-compose.yml` + volume للبيانات + volume للملفات المرفوعة.
- الـ **Makefile** بينضاف عليه أوامر: `db-up`, `migrate`, `seed`, `studio` (Prisma Studio).
- migrations تنطبق تلقائياً عند النشر (خطوة بالـ start أو entrypoint).
- أسرار (SMTP, DB URL, AUTH secret) بـ `.env` فقط — **ولا تُرفع للـ git**.
+99
View File
@@ -0,0 +1,99 @@
# ✅ التشيك لست الشاملة — diyaa.de
> نظرة عامة على **كل شي** لازم يتبنى، مقسّم لمراحل. للمهام القابلة للتنفيذ حالاً شوف [`TODO.md`](./TODO.md).
> `[ ]` = ناقص · `[~]` = جزئي/موجود ناقص · `[x]` = جاهز
---
## المرحلة 0 — الأساس (Foundation)
- [x] مشروع Next.js 14 + TS + Docker + Traefik + Makefile
- [x] i18n عربي/إنجليزي + ثيم فاتح/غامق
- [x] وضع coming-soon
- [x] تهيئة **Tailwind CSS** (نقل متغيّرات الثيم لإعداد Tailwind)
- [x] تهيئة **shadcn/ui** (`components/ui/`)
- [x] إعداد **PostgreSQL** بـ docker-compose + volume
- [x] إعداد **Prisma** + `schema.prisma` أولي + أول migration
- [x] `lib/db.ts` (Prisma client singleton)
## المرحلة 1 — المصادقة ولوحة التحكم (Admin core)
- [x] **Auth.js** (Credentials) + جدول User + كلمة سر مشفّرة
- [x] هيكل `app/admin/` محمي بالجلسة
- [x] Layout للوحة التحكم (سايدبار، عناصر مشتركة)
- [x] فورمات أساسية (React Hook Form + Zod) قابلة لإعادة الاستخدام
> ⚠️ **قرار 2026-08-05 (الخيار A):** الداتابيس صارت `Project` موحّد (PORTFOLIO/APP/WEBSITE/DESIGN) + `Melody` منفصل. المخطط القديم `Work/App` بينستبدل — شوف مهمة refactor بـ `TODO.md`.
## المرحلة 1.5 — إصلاحات معلّقة (من مراجعة 2026-08-05)
- [x] 🐞 إصلاح `.env.example`: `live``full` + اعتماد قيمة `maintenance`
- [x] إعادة تشكيل `schema.prisma` للخيار A (`Project` موحّد + `Melody`) + migration جديد
- [x] مواءمة CRUD التصنيفات مع `CategoryKind = PROJECT | MELODY`
## المرحلة 2 — التصنيفات (Categories)
- [x] موديل Category
- [x] CRUD تصنيفات من لوحة التحكم
- [x] مواءمة الأنواع للخيار A (PROJECT / MELODY)
- [ ] تصنيفات المشاريع (شعارات، بروشورات، مواقع، متاجر، أدوات AI…)
- [ ] تصنيفات الألحان (عربي، شرقي…)
## المرحلة 3 — رفع الملفات (Media)
- [x] `lib/upload.ts` — رفع لـ volume على الـ VPS
- [x] معالجة صور بـ `sharp` (webp + أحجام)
- [x] رفع صوت للألحان
- [x] التحقق من نوع/حجم الملف + أسماء ملفات آمنة
- [ ] **حذف الملف الفعلي عند حذف المحتوى** + `altText` للصور
- [x] مكوّن رفع قابل لإعادة الاستخدام (صور + صوت)
## المرحلة 4 — المشاريع: أعمال + مواقع + تصاميم (Project)
- [x] موديل `Project` موحّد + migration
- [x] **باك اند: إضافة/تعديل/حذف مشروع** + اختيار `type` + تصنيف + صور + تقنيات + `isFeatured`/`sortOrder`
- [x] **فرونت اند: صفحة الأعمال** (شبكة + فلترة بالتصنيف) + تفاصيل `[slug]`
- [x] **فرونت اند: صفحة المواقع** (Next.js/WordPress) + سكرين شوتس
- [x] **فرونت اند: صفحة التصاميم** (شعارات/بروشورات/هويات)
## المرحلة 5 — التطبيقات (Apps · type=APP)
- [x] **باك اند: إضافة تطبيق Swift** مع سكرين شوتس + حقول iOS
- [x] حقول: App Store / TestFlight / رقم الإصدار / رابط الدعم / سياسة خصوصية التطبيق
- [x] **فرونت اند: قسم التطبيقات** + معرض سكرين شوتس بصفحة التفاصيل
## المرحلة 6 — الألحان (Melodies)
- [x] موديل Melody + migration
- [x] **باك اند: إضافة لحن جديد** + تصنيف + ملف صوت + غلاف + سماح/منع تحميل
- [x] **فرونت اند: قسم الألحان** + **مشغّل صوت** قابل لإعادة الاستخدام
- [x] المشغّل: تشغيل/إيقاف + تقديم/تأخير + مستوى صوت + مدة + تحميل اختياري
- [x] تصنيفات الألحان (عربي/شرقي) بالفلترة
## المرحلة 7 — الإيميلات و SMTP
- [x] `lib/email.ts` — Nodemailer + إعداد **SMTP**
- [x] قوالب **React Email** (تأكيد تواصل، تنبيه للمالك) + قالب نصي بديل
- [x] ربط نموذج التواصل → حفظ بالداتابيس + إرسال إيميل
- [x] **Honeypot + Rate limiting** لنموذج التواصل
- [x] متغيّرات SMTP بـ `.env.example`
## المرحلة 8 — الصفحات الرسمية
- [x] سياسة الخصوصية (ar/en)
- [x] شروط الاستخدام (ar/en)
- [x] **Impressum** (إلزامي قانونياً بألمانيا — دومين `.de`)
- [x] روابط الصفحات بالـ footer
## المرحلة 9 — الاحصائيات (Analytics) — عدّادات داخلية أولاً
- [x] عدّادات داخلية بسيطة: زيارات الصفحات، فتح المشاريع، تشغيل الألحان، نقر روابط المشاريع، عدد رسائل التواصل
- [x] لوحة احصائيات مصغّرة داخل الأدمن
- [ ] (لاحقاً فقط لو لزم) Umami self-hosted للتحليلات المتقدمة
## المرحلة 10 — coming-soon و الصيانة
- [x] وضع coming-soon أساسي
- [ ] وضع **صيانة** منفصل (رسالة صيانة + رجوع للأدمن)
- [ ] تحكّم بالأوضاع من env بشكل موثّق
## المرحلة 11 — البنية التحتية والتشغيل
- [ ] تحديث **Makefile**: `db-up`, `migrate`, `seed`, `studio`, أوامر لوكال + سيرفر
- [x] docker-compose: خدمة postgres + volume البيانات
- [ ] تطبيق migrations تلقائياً عند النشر
- [ ] نسخ احتياطي للداتابيس (backup) موثّق
## عابرة لكل المراحل (Cross-cutting)
- [ ] كل شي **مكوّنات قابلة لإعادة الاستخدام**
- [ ] كل المكتبات **محليّة** (بدون CDN وقت التشغيل)
- [ ] الفرونت اند مطابق لمعايير **Tailwind + shadcn**
- [ ] تحديث `PROJECT-STATE.md` بعد كل خطوة كبيرة
- [ ] نقل المهام المنجزة من `TODO.md` لقسم Ready
+42
View File
@@ -0,0 +1,42 @@
# 💡 أفكار مستقبلية — diyaa.de
> مكان لتجميع الأفكار **قبل** ما تتجدول. لما نقرّر ننفّذ فكرة، بتنتقل لـ [`TODO.md`](./TODO.md) كمهمة.
> ضيف فكرة بأي وقت — سطر عنوان + وصف قصير + (اختياري) أولوية.
**الأولوية:** 🔥 عالية · ⭐ متوسطة · 💤 لاحقاً
---
## أفكار مطروحة
### 💤 مشاركة الألحان للناس ليستمتعوا فيها
صفحة عامة تعرض الألحان مع مشغّل جميل، إمكانية مشاركة رابط لكل لحن، وربما playlist. (المالك ذكرها كطموح.)
### ⭐ RSS / Podcast feed للألحان
توليد feed للألحان عشان الناس تتابعها من تطبيقات البودكاست.
### 💤 صفحة "المدوّنة / كتابات"
قسم مقالات بالماركداون (MDX) لمشاركة أفكار تقنية أو موسيقية.
### ⭐ وضع معاينة (Preview) للمحتوى المسودّة
رابط خاص يعاين العمل/اللحن قبل نشره وهو لسا DRAFT.
### 💤 بحث داخل الموقع
بحث بسيط عبر الأعمال والتطبيقات والألحان.
### 💤 صفحة "خدمات" مع نموذج طلب
لو حاب تعرض خدماتك (تصميم، تطوير، ألحان) مع نموذج طلب عرض سعر.
### ⭐ تحسينات SEO متقدمة
structured data (JSON-LD) لكل عمل/تطبيق/لحن + OG images ديناميكية لكل عنصر.
### 💤 دعم لغات إضافية
البنية جاهزة لـ ar/en — ممكن إضافة لغة ثالثة لاحقاً.
### 💤 تصدير/استيراد المحتوى
أداة نسخ احتياطي للمحتوى (JSON) من لوحة التحكم.
---
### تعليمات
لما تقرّر تنفّذ فكرة: انسخها لـ `TODO.md` كمهمة `T-##`، واحذفها من هون أو علّمها ✅ منقولة.
+156
View File
@@ -0,0 +1,156 @@
# 🗺️ الوضع الحالي للموقع — diyaa.de
> **الهدف من الملف:** أي agent يقرأه بيعرف الوضع الحالي للموقع **بدون ما يقرأ الكود**.
> آخر تحديث: 2026-08-05
---
## نظرة عامة بسطر
موقع **Next.js 14** شخصي، ثنائي اللغة (عربي/إنجليزي)، حالياً بوضع **coming-soon**، منشور عبر **Docker + Traefik** على VPS خاص (STRATO). قاعدة PostgreSQL وPrisma ومصادقة الأدمن ومخطط Project/Melody وCRUD المشاريع والتطبيقات والألحان ورفع الوسائط وقسم الألحان العام ومشغّل الصوت ونموذج التواصل والصفحات الرسمية والإحصائيات الداخلية صارت مهيّأة.
---
## ✅ الموجود فعلياً حالياً
### الإطار والأدوات
- **Next.js 14.2** (App Router) + **React 18** + **TypeScript** (strict).
- إخراج `standalone` للنشر عبر Docker.
- **Tailwind CSS 3.4** مهيّأ عبر `tailwind.config.js` و`postcss.config.js`، مع ربط ألوان وظلال وخطوط الثيم بمتغيّرات CSS الحالية.
- **shadcn/ui** مهيّأ عبر `components.json`، مع مكوّنات UI محلية أساسية في `components/ui/` وأداة `cn` في `lib/utils.ts`.
- **PostgreSQL 16** مضاف إلى `docker-compose.yml` مع volume دائم، شبكة داخلية، وفحص صحة.
- **Prisma 6.19** مضاف مع `prisma/schema.prisma`، migration أولى، و`lib/db.ts` كـ singleton.
- **Auth.js Credentials** مضاف مع جلسات JWT، تشفير كلمات السر بـ `bcryptjs`، وmiddleware يحمي `/admin/`.
- **Admin layout** مضاف مع سايدبار، ترويسة صفحات مشتركة، وفورم reusable مبني على React Hook Form وZod.
- **Categories CRUD** مضاف داخل لوحة الأدمن مع تحقق Zod ودعم أنواع PROJECT / MELODY.
- **Project/Melody schema** مضاف عبر migration جديدة، مع أنواع المشاريع وحقول التطبيقات وحقول الترتيب والنشر.
- **Project CRUD + public directories** مضاف للمشاريع الأساسية والتطبيقات، مع صفحات الأعمال والمواقع والتصاميم والتطبيقات وفلترة حسب التصنيف وصفحات تفاصيل حسب slug.
- **Melody CRUD + public directory** مضاف مع إدارة الصوت والغلاف والتصنيف والنشر والتحميل، وصفحات قائمة/تفاصيل عامة ومشغّل صوت reusable.
- **Media upload** مضاف مع تخزين دائم على volume، معالجة الصور إلى WebP عبر sharp، وحفظ ملفات الصوت بصيغتها الأصلية، ومربوط بنماذج المشاريع والألحان.
- **Contact messaging** مضاف مع نموذج عام ثنائي اللغة، حفظ `ContactMessage`، إرسال إشعار للمالك وتأكيد للمرسل عبر Nodemailer/SMTP، وقالب HTML بـ React Email مع بديل نصي.
- **Contact protection** مضاف عبر Honeypot وتحديد المعدل بخمسة طلبات لكل عنوان مصدر خلال عشر دقائق.
- **Legal pages** مضافة بالعربي والإنجليزي: الخصوصية، شروط الاستخدام، وImpressum، مع روابط في الفوتر وحقول الهوية القانونية من `.env`.
- **Internal analytics** مضافة عبر `AnalyticsEvent` وendpoint محمي بالتحديد والتحقق، مع عدّادات للزيارات وفتح المشاريع وتشغيل الألحان ونقر الروابط ورسائل التواصل ولوحة أدمن مختصرة.
- ترويسات أمان مضبوطة بـ `next.config.js` (Referrer-Policy, X-Frame-Options, nosniff, Permissions-Policy).
- alias `@/*` → جذر المشروع (بـ `tsconfig.json`).
### التنسيق (Styling) — ⚠️ الانتقال بدأ
- **Tailwind CSS** شغّال عبر PostCSS، وtokens الثيم متاحة كألوان وظلال وخطوط ضمن `tailwind.config.js`.
- مكوّنات **shadcn/ui** الأساسية مضافة محلياً وقابلة لإعادة الاستخدام؛ قواعد المكوّنات العامة الحالية ما زالت موجودة في `app/globals.css` كطبقة انتقالية بانتظار ترحيلها للـ utilities.
- في نظام theme (فاتح/غامق) عبر `[data-theme]` ومتغيرات CSS + مكوّن `ThemeToggle`.
### التعدّد اللغوي (i18n)
- لغتان: `ar` و `en` (`lib/i18n.ts`).
- المحتوى مخزّن كـ dictionaries: `content/ar.ts`، `content/en.ts`، بأنواع من `content/types.ts`.
- اتجاه تلقائي RTL للعربي / LTR للإنجليزي.
- بوضع coming-soon: بيُجبر الموقع على الإنجليزي فقط.
### الإعدادات والأوضاع
- كل الإعداد عبر متغيرات بيئة (`lib/site.ts`).
- ثلاثة أوضاع معتمدة: `NEXT_PUBLIC_SITE_MODE = coming-soon | maintenance | full`؛ وضع الصيانة حالياً قيمة محجوزة إلى حين تنفيذ واجهته في T-14.
- بيانات المالك: الاسم "Diyaa"، إيميل، LinkedIn، GitHub — كلها من env.
### الصفحات الموجودة (`app/`)
- `app/[locale]/page.tsx` — الرئيسية (Hero).
- `app/[locale]/about/page.tsx` — نبذة.
- `app/[locale]/contact/page.tsx` — تواصل مع نموذج حفظ وإرسال الرسائل.
- `app/[locale]/legal/` — الخصوصية، شروط الاستخدام، وImpressum بالعربي والإنجليزي.
- `app/admin/(protected)/analytics/page.tsx` — ملخص الإحصائيات الداخلية لآخر 30 يوماً.
- `app/api/analytics/route.ts` — endpoint تسجيل الأحداث العامة مع Zod وrate limiting.
- `app/[locale]/melodies/` — قائمة الألحان العامة مع فلترة التصنيفات وصفحات التفاصيل.
- `app/not-found.tsx` — 404.
- `app/api/health/route.ts` — health check للـ Docker.
- `app/robots.ts` + `app/sitemap.ts` — SEO.
- صور OG/Twitter/icon جاهزة.
### المكوّنات الموجودة (`components/`)
`SiteHeader` · `SiteFooter` · `HeroSection` · `ComingSoonPanel` · `BottomNav` · `ThemeToggle` · `LanguageSwitcher` · `ContactForm`
### النشر والبنية التحتية
- **Dockerfile** متعدّد المراحل (deps → builder → runner) بمستخدم غير-root.
- **docker-compose.yml** مربوط بشبكة **Traefik** خارجية، مع labels للـ HTTPS (`Host(diyaa.de)`, websecure, LetsEncrypt).
- **Makefile** بأوامر: `build` · `start` · `stop` · `restart` · `logs` · `ps` (مع فحص `.env` و `COMPOSE_PROJECT_NAME`).
- healthcheck عبر `/api/health`.
- سكربتات npm: `dev` · `build` · `start` · `lint` · `typecheck` · `check`.
---
## ❌ الناقص (لسا ما تبنى)
| المجال | الحالة |
|--------|--------|
| **Tailwind + shadcn/ui** | 🟡 Tailwind وshadcn/ui مهيّآن؛ ترحيل المكوّنات العامة من CSS القديم لسا ناقص |
| **قاعدة بيانات (PostgreSQL + Prisma)** | 🟡 مخطط Project/Melody وmigration جاهزان؛ CRUD المحتوى وباقي الداتا ناقصان |
| **باك اند / لوحة تحكم (Admin)** | 🟡 layout وdashboard وCRUD التصنيفات والمشاريع والتطبيقات والألحان جاهزة؛ إدارة التواصل وباقي المحتوى لسا ناقصة |
| **مصادقة (Auth) للأدمن** | 🟡 Auth.js Credentials وحماية `/admin/` جاهزان؛ إدارة المحتوى لسا ناقصة |
| **معرض الأعمال + التصنيفات** | 🟡 Project CRUD والصفحات العامة للأعمال والمواقع والتصاميم جاهزة؛ المحتوى المنشور ناقص |
| **قسم التطبيقات (Swift/Next.js) مع سكرين شوتس** | 🟡 CRUD وحقول iOS ومعرض السكرين شوتس جاهزين؛ محتوى التطبيقات المنشور ناقص |
| **قسم الألحان + مشغّل + تصنيفات** | 🟡 CRUD وقائمة/تفاصيل عامة ومشغّل وفلترة جاهزة؛ المحتوى المنشور لسا ناقص |
| **إيميلات + SMTP** | 🟡 التكامل والقوالب والنموذج جاهزة؛ يلزم وضع بيانات SMTP الحقيقية في `.env` |
| **الاحصائيات (Analytics)** | 🟡 عدّادات داخلية وواجهة أدمن جاهزة؛ لا توجد تحليلات خارجية متقدمة |
| **الصفحات الرسمية (سياسة الخصوصية، الشروط...)** | 🟡 الصفحات والروابط جاهزة؛ يلزم تعبئة بيانات الهوية ومراجعة النصوص قانونياً |
| **رفع الملفات (صور/صوت)** | 🟡 API وvolume ومعالجة الصور ومكوّن الرفع وربطه بالمشاريع والألحان جاهز؛ حذف الملفات الفعلية لسا ناقص |
---
## 🧭 خريطة المجلدات الحالية
```
diyaa.de/
├── app/
│ ├── [locale]/ # الصفحات المترجمة (home, about, contact, melodies)
│ ├── api/health/ # health check
│ ├── api/auth/ # Auth.js handlers
│ ├── api/upload/ # رفع ملفات محمي للأدمن
│ ├── api/uploads/ # تقديم الملفات المرفوعة
│ ├── admin/ # login + layout + dashboard محميان بالمصادقة
│ │ └── (protected)/categories/ # صفحات وإجراءات CRUD للتصنيفات
│ │ └── (protected)/projects/ # صفحات وإجراءات CRUD للمشاريع
│ │ └── (protected)/melodies/ # صفحات وإجراءات CRUD للألحان
│ ├── [locale]/work/ # الأعمال المنشورة + [slug]
│ ├── [locale]/apps/ # التطبيقات المنشورة + [slug]
│ ├── [locale]/websites/ # المواقع المنشورة + [slug]
│ ├── [locale]/designs/ # التصاميم المنشورة + [slug]
│ ├── globals.css # متغيرات CSS + الثيم
│ ├── layout.tsx · robots.ts · sitemap.ts · not-found.tsx
├── components/ # مكوّنات الواجهة الحالية
│ └── ui/ # مكوّنات shadcn/ui الأساسية
├── prisma/ # schema.prisma + migrations (Project/Melody)
├── content/ # قواميس الترجمة (ar/en) + الأنواع
├── lib/ # auth · db · upload · i18n · site config · metadata · utils · validations
├── components/admin/ # shell · page header · reusable form
├── components/public/ # project/melody cards, details, directory, audio player
├── middleware.ts # حماية مسارات admin
├── public/ # أصول ثابتة
├── docs/ # 📚 التوثيق (هون)
├── Dockerfile · docker-compose.yml · Makefile
└── .env.example
```
---
## 📌 Changelog (خطوات كبيرة)
- **2026-08-03** — إنشاء منظومة التوثيق بمجلد `docs/` (STATE, STACK, ARCHITECTURE, CHECKLIST, TODO, IDEAS) + `AGENTS.md`. توثيق الوضع الحالي كقاعدة انطلاق.
- **2026-08-05** — إنجاز **T-01**: تهيئة Tailwind CSS وPostCSS وربط tokens الثيم الحالية مع الحفاظ على الوضعين الفاتح والغامق.
- **2026-08-05** — إنجاز **T-02**: تهيئة shadcn/ui وإضافة Button وCard وInput وDialog وSelect وForm كمكوّنات محلية قابلة لإعادة الاستخدام.
- **2026-08-05** — إنجاز **T-03**: إضافة PostgreSQL 16 إلى Docker، مخطط Prisma الأولي، migration `init`، وعميل قاعدة البيانات singleton.
- **2026-08-05** — إنجاز **T-04**: إضافة Auth.js Credentials، تشفير كلمات السر، حماية `/admin/`، صفحة تسجيل الدخول، وseed آمن لمستخدم الأدمن.
- **2026-08-05** — إنجاز **T-05**: إضافة layout لوحة الأدمن، سايدبار، عناصر صفحات مشتركة، وفورم عام قابل لإعادة الاستخدام.
- **2026-08-05** — إنجاز **T-06**: إضافة تحقق Zod وCRUD للتصنيفات داخل لوحة الأدمن مع دعم أنواع PROJECT / MELODY.
- **2026-08-05** — إنجاز **T-07**: إضافة volume للملفات المرفوعة، تخزين محلي، معالجة صور بـ sharp، endpoint محمي، ومكوّن رفع reusable للصور والصوت.
- **2026-08-05** — إنجاز **T-16**: استبدال Work/App بـ Project موحّد، تحديث CategoryKind إلى PROJECT/MELODY، إضافة حقول iOS والترتيب والنشر، وتطبيق migration الجديدة.
- **2026-08-05** — إنجاز **T-08**: إضافة CRUD المشاريع في الأدمن، رفع الصور من النموذج، وصفحات الأعمال والمواقع والتصاميم مع الفلترة والتفاصيل.
- **2026-08-05** — إنجاز **T-09**: إضافة CRUD تطبيقات APP وحقول iOS، فلتر التطبيقات في الأدمن، وقسم عام مع معرض السكرين شوتس وروابط التطبيقات.
- **2026-08-05** — إنجاز **T-10**: إضافة CRUD الألحان مع رفع الصوت والغلاف والتحكم بالتحميل، الصفحات العامة والتفاصيل، فلترة التصنيفات، ومشغّل صوت reusable بعناصر التحكم الأساسية.
- **2026-08-05** — إنجاز **T-11**: ربط نموذج التواصل بحفظ الرسائل وإرسال إشعار للمالك وتأكيد للمرسل عبر SMTP، إضافة قوالب React Email وبديل نصي، وحماية Honeypot وRate limiting.
- **2026-08-05** — إنجاز **T-12**: إضافة صفحات الخصوصية وشروط الاستخدام وImpressum بالعربي والإنجليزي، ربط الفوتر، وإضافة إعدادات الهوية القانونية.
- **2026-08-05** — إنجاز **T-13**: إضافة `AnalyticsEvent` وmigration، تتبع الزيارات والتفاعلات الأساسية، endpoint محمي، ولوحة إحصائيات داخل الأدمن.
- **2026-08-05** — إنجاز **T-17**: تصحيح `.env.example` واعتماد أوضاع `coming-soon` و`maintenance` و`full` في طبقة إعدادات الموقع، مع إبقاء واجهة الصيانة ضمن T-14.
- **2026-08-05** — **مراجعة + قرارات** (`REVIEW-2026-08-05.md`): بعد مقارنة تشيك لست ChatGPT مع الواقع، اعتُمد: (1) مخطط **الخيار A**`Project` موحّد + `Melody` منفصل يستبدل `Work/App`؛ (2) الاحصائيات **عدّادات داخلية** أولاً بدل Umami؛ (3) إبقاء نظام التوثيق الحالي (6 ملفات)؛ (4) تبنّي إضافات: **Impressum**، حقول iOS، Honeypot/Rate-limit، تفاصيل مشغّل الألحان، فصل صفحات المواقع/التصاميم؛ (5) رصد باگ `.env.example` (`live``full`) وغياب `maintenance`. أُضيفت مهام **T-16** (refactor المخطط) و**T-17** (إصلاح env). *(المراجعة موثّقة، وT-16/T-17 أُنجزتا؛ واجهة الصيانة الفعلية لسا متروكة لـ T-14.)*
- *(قبل التوثيق)* — موقع coming-soon ثنائي اللغة منشور عبر Docker + Traefik.
---
### تعليمات تحديث هذا الملف
عند إنجاز أي feature: حدّث جدول "الموجود" و"الناقص"، وأضف سطر بالـ Changelog. خلّي هالملف يعكس **الواقع** لا الطموح.
+27
View File
@@ -0,0 +1,27 @@
# 📚 توثيق مشروع diyaa.de
هاد المجلد هو **مصدر الحقيقة الوحيد** لحالة المشروع وخططه. أي agent أو شخص لازم يقرأ من هون **قبل ما يلمس الكود**، وأي خطوة كبيرة لازم تنوثّق هون بعدها.
## الملفات
| الملف | شو فيه | إمتى تقرأه |
|------|--------|-----------|
| [`PROJECT-STATE.md`](./PROJECT-STATE.md) | الوضع الحالي الفعلي للموقع — شو موجود وشو شغّال | **أول شي دايماً** لتعرف الحالة بدون قراءة الكود |
| [`STACK.md`](./STACK.md) | الستاك الموصى به وأسبابه | عند اتخاذ قرار تقني أو إضافة مكتبة |
| [`ARCHITECTURE.md`](./ARCHITECTURE.md) | المعمارية المستهدفة، مخطط الداتابيس، اتفاقيات المجلدات والمكونات | قبل ما تبني أي feature جديد |
| [`CHECKLIST.md`](./CHECKLIST.md) | تشيك لست شاملة لكل شي لازم يتبنى | نظرة عامة على المتبقّي |
| [`TODO.md`](./TODO.md) | المهام الحالية القابلة للتنفيذ مع حالاتها | عند السؤال "شو عنا شي نعملو؟" |
| [`IDEAS.md`](./IDEAS.md) | أفكار مستقبلية لسا مو مجدولة | لما يخطر ببال أفكار جديدة |
## سير العمل للـ Agents (مهم)
اقرأ [`../AGENTS.md`](../AGENTS.md) بالتفصيل. الخلاصة:
1. **قبل أي شغل** → اقرأ `PROJECT-STATE.md` عشان تفهم الوضع، وافحص `TODO.md` عشان تعرف المهام المفتوحة.
2. **عند اقتراح شغل** → خد المهام يلي حالتها `TODO` من `TODO.md` واقترحها.
3. **بعد إنجاز مهمة**
- انقل المهمة من قسم `## 🔵 قيد التنفيذ / مفتوحة` لقسم `## ✅ منجزة (Ready)` بملف `TODO.md`.
- حدّث `PROJECT-STATE.md` ليعكس الوضع الجديد.
- إذا كانت خطوة كبيرة، وثّقها بسطر بالـ changelog بآخر `PROJECT-STATE.md`.
> القاعدة الذهبية: **لا تخلّي الكود يسبق التوثيق.** إذا الكود تغيّر والتوثيق لأ — هاد باگ.
+91
View File
@@ -0,0 +1,91 @@
# 🔍 مراجعة: تشيك لست ChatGPT مقابل الواقع — 2026-08-05
> **✅ الحالة: القرارات معتمدة (2026-08-05).** انعكست على ملفات `docs/`. **التنفيذ على الكود لسا لم يبدأ.**
> المصدر المُراجَع: `diyaa-website-clean-checklist-dropdown.md` (من ChatGPT).
## ✅ القرارات المعتمدة
1. **المخطط:** الخيار A — `Project` موحّد + `Melody` منفصل، technologies/screenshots كـ `String[]`، تصنيف واحد-لواحد. → مهمة **T-16**.
2. **الاحصائيات:** عدّادات داخلية أولاً؛ Umami مؤجّل. → **T-13**.
3. **التوثيق:** إبقاء نظامنا الحالي (6 ملفات)، نوسّع عند الحاجة.
4. **التبنّيات الخضراء:** كلها معتمدة (Impressum، حقول iOS، Honeypot/Rate-limit، تفاصيل المشغّل، فصل صفحات المواقع/التصاميم).
5. **إصلاح env:** `live``full` + إضافة `maintenance`. → **T-17**.
**الملفات المحدَّثة:** `ARCHITECTURE.md` (مخطط + أقسام + مجلدات + أوضاع) · `CHECKLIST.md` · `TODO.md` (T-16/T-17 + تعديلات) · `STACK.md` · `PROJECT-STATE.md`.
---
### (أدناه المراجعة الأصلية كما كُتبت — للمرجع)
## الخلاصة
التشيك لست تبع ChatGPT **منظّمة وشاملة ومفيدة**، وفيها إضافات أحسن من ملفاتنا (Impressum، حقول iOS، فصل الصفحات، Honeypot/Rate-limit). بس فيها **تعارضات حقيقية** مع يلي **انبنى فعلياً** بالمشروع (T-01→T-05) ومع ملفات `docs/`. لو طبّقناها حرفياً رح نكسر شغل شغّال. أدناه التعارضات + التوصية لكل واحد.
---
## 🔴 تعارضات لازم قرار
### 1) بنية `src/` — تجاهلها
ChatGPT يقترح `src/app`, `src/features`, `src/styles`, `src/types`, `src/config`.
**الواقع:** المشروع مبني **بالجذر** (`app/`, `components/`, `lib/`, `content/`) ومشغّل.
**التوصية:** ❌ تجاهل `src/`. الانتقال إليها = إعادة هيكلة كل الموجود بلا فائدة تقنية.
### 2) طبقة `features/` — اختيارية، مؤجَّلة
ChatGPT يضيف layer `features/` (projects, applications, categories, tracks, media, contact, settings).
**الواقع:** غير موجود؛ المنطق بـ `lib/` + `app/`.
**التوصية:** 🟡 احتفظ بالنمط الحالي الآن. `features/` مفيدة لو كبر المشروع كثير، بس إضافتها الحين تعقيد مبكّر.
### 3) مخطط قاعدة البيانات — أكبر تعارض ⚠️
| | ChatGPT | المبني فعلياً (`prisma/schema.prisma`) |
|---|---|---|
| النموذج | جدول موحّد `Project` + حقل `type` | جداول منفصلة `Work` / `App` / `Melody` |
| التصنيفات | `Category` + join `project_categories` | `Category` (enum `CategoryKind`) علاقة مباشرة |
| التقنيات | جدول `Technology` + join `project_technologies` | ❌ غير موجود |
| الوسائط | جدول `Media` منفصل (altText, mime, size, order) | حقول inline (`coverImage`, `images[]`, `screenshots[]`) |
**تصميمان مختلفان جوهرياً.** المبني حالياً يشتغل وما فيه داتا بعد (فالتغيير آمن).
**التوصية (وسط):** وحّد **Work + المواقع + التصاميم + التطبيقات** بجدول `Project` واحد مع `type` (`PORTFOLIO | APP | WEBSITE | DESIGN`)، واحتفظ بـ **`Melody` منفصل** (طبيعته صوتية مختلفة). هيك تلغي تكرار الجداول وتكسب "أنواع قابلة للإضافة"، وتضيف `Technology` (للفلترة) و`Media` (لـ alt text والترتيب) من ChatGPT.
**البديل الأخف:** خلّي `Work/App/Melody` كما هي وزيد `WEBSITE` و`DESIGN` كقيم بـ `CategoryKind` — شغل أقل، مرونة أقل.
> أي خيار = migration جديد يستبدل الحالي (آمن، لا داتا).
### 4) الاحصائيات — تعارض مباشر
- **ChatGPT:** ❌ لا Umami. اعمل عدّادات داخلية بسيطة (زيارات صفحات، فتح مشاريع، تشغيل ألحان، نقر روابط، رسائل تواصل).
- **`STACK.md` تبعنا:** ✅ Umami/Plausible self-hosted.
**التوصية:** ابدأ بـ **عدّادات داخلية بسيطة** (اقتراح ChatGPT) للإطلاق الأول — أخف وأسرع وخصوصية أعلى. أضف Umami لاحقاً فقط لو احتجت تحليلات متقدمة. (نحدّث `STACK.md` بعد الموافقة.)
### 5) نظام التوثيق — نظامان لازم يصيروا واحد
ChatGPT يقترح 12 ملف: `CURRENT_STATUS.md`, `ARCHITECTURE.md`, `DATABASE.md`, `DESIGN_SYSTEM.md`, `DEPLOYMENT.md`, `EMAIL.md`, `UPLOADS.md`, `SECURITY.md`, `CHANGELOG.md`, `IDEAS.md`, `TODO.md`, `ROADMAP.md`.
عندنا 6: `PROJECT-STATE.md`, `STACK.md`, `ARCHITECTURE.md`, `CHECKLIST.md`, `TODO.md`, `IDEAS.md`.
**تضارب أسماء:** `CURRENT_STATUS.md``PROJECT-STATE.md` · ChatGPT يفصل `CHANGELOG.md` بينما نحن مدمجينه جوا `PROJECT-STATE.md`.
**التوصية:** نظام واحد. أنصح نبقى على أسماءنا الحالية وننقل الملفات المتخصصة (DATABASE/EMAIL/UPLOADS/SECURITY/DEPLOYMENT) فقط لما نبنيها فعلاً — لا نعمل 12 ملف فاضي من الآن. (نضيف `CHANGELOG.md` منفصل إذا حبيت.)
### 6) Makefile — احتفظ بالحالي
Makefile تبع ChatGPT أبسط، بس:
- **يفقد** حمايات موجودة: فحص `.env`، فحص `COMPOSE_PROJECT_NAME`، `--env-file`، اسم مشروع Traefik.
- **يضيف** `test` و`check: ... test` بينما **ما في test setup**`make check` رح يفشل.
**التوصية:** ❌ لا تستبدل. احتفظ بالـ Makefile الحالي، وزيد عليه بس أوامر Prisma (`db-migrate`, `db-studio`, `seed`) لاحقاً.
### 7) قيم `SITE_MODE` — تخبيص فعلي بمشروعك 🐞
- ChatGPT: `full | maintenance | coming-soon`.
- الكود الفعلي (`lib/site.ts`): بس `coming-soon | full`**ما في `maintenance`**.
- `.env.example` مكتوب فيه `# coming-soon | live` — كلمة **`live`** غلط، الكود بدّو **`full`**.
**التوصية:** ✅ صلّح `.env.example` (`live``full`)، وأضف قيمة `maintenance` للكود (مطلوبة أصلاً بالتشيك لست، T-14).
---
## 🟢 نقاط ChatGPT أفضل — نتبنّاها
- **Impressum** — صفحة **إلزامية قانونياً بألمانيا** (دومين `.de` + سيرفر STRATO). ملفاتنا ما ذكرتها. **نضيفها لـ T-12.**
- **حقول iOS للتطبيقات** — App Store / TestFlight / رقم الإصدار / رابط الدعم / سياسة خصوصية لكل تطبيق. **نضيفها لموديل التطبيق.**
- **فصل الصفحات**: أعمال / تطبيقات / مواقع / تصاميم / ألحان — تقسيم UI أوضح من دمجها.
- **نموذج التواصل:** **Honeypot + Rate limiting** — أمان جيد ما ذكرناه. **نضيفه لـ T-11.**
- **مشغّل الألحان:** تحكم مستوى الصوت + تقديم/تأخير + سماح/منع التحميل — تفاصيل نضيفها لـ T-10.
- **الوسائط:** حذف الملف الفعلي عند حذف المحتوى + `altText` للصور — نضيفه لـ T-07.
- **SEO الأساسي بإعدادات الموقع** + `publishedAt` / `isFeatured` / `sortOrder` على المشاريع — إضافات مفيدة.
---
## 📋 القرارات المطلوبة منك (قبل أي تعديل)
1. **المخطط:** وسط موحّد (`Project`+`Melody`) / إبقاء الحالي (`Work/App/Melody`) + إضافة WEBSITE/DESIGN كتصنيفات / تبنّي مخطط ChatGPT الكامل؟
2. **الاحصائيات:** عدّادات داخلية أولاً (توصيتي) / Umami من الآن؟
3. **التوثيق:** إبقاء نظامنا (6 ملفات، نوسّع عند الحاجة) / تبنّي هيكل ChatGPT (12 ملف)؟
4. **موافقة على التبنّيات الخضراء** (Impressum، حقول iOS، Honeypot، تفاصيل المشغّل، فصل الصفحات)؟
> بعد موافقتك: أحدّث `TODO.md` / `CHECKLIST.md` / `ARCHITECTURE.md` / `.env.example` وفق القرارات، وأوثّق كل تغيير بالـ Changelog.
+75
View File
@@ -0,0 +1,75 @@
# 🧱 الستاك الموصى به — diyaa.de
> القرار: **Next.js فل ستاك + PostgreSQL + Prisma**. كل شي بمشروع واحد، مكوّنات قابلة لإعادة الاستخدام، ومكتبات محليّة (self-hosted) قدر الإمكان.
>
> **تحديث 2026-08-05:** المخطط اعتُمد على **الخيار A** — جدول `Project` موحّد + `Melody` منفصل (تفاصيله بـ `ARCHITECTURE.md §3`). الاحصائيات: **عدّادات داخلية أولاً** بدل Umami. راجع قرارات المراجعة بـ `REVIEW-2026-08-05.md`.
---
## الخلاصة السريعة
| الطبقة | الاختيار | ليش |
|--------|---------|-----|
| **Framework** | Next.js 14 (App Router) — *موجود* | فرونت + باك اند بمشروع واحد، Server Actions & Route Handlers |
| **اللغة** | TypeScript (strict) — *موجود* | أمان أنواع، أسهل للـ agents يفهموا الكود |
| **التنسيق** | **Tailwind CSS** | معيار مطلوب، utility-first، متناسق |
| **مكوّنات UI** | **shadcn/ui** (Radix + Tailwind) | مكوّنات محليّة (بتتنسخ للريبو مش package)، قابلة للتعديل الكامل |
| **قاعدة البيانات** | **PostgreSQL** | علاقات قوية (أعمال ↔ تصنيفات ↔ ألحان)، ممتاز محلياً وعلى السيرفر |
| **ORM** | **Prisma** | Schema واحد = مصدر الحقيقة، migrations منظّمة، type-safe |
| **المصادقة (Admin)** | **Auth.js (NextAuth v5)** بـ Credentials | لوحة تحكم لك وحدك، بدون طرف ثالث |
| **رفع الملفات** | تخزين محلي على الـ VPS (volume) + معالجة بـ **sharp** | صور الأعمال والسكرين شوتس والصوت — self-hosted، بدون S3 إلزامي |
| **الصوت (الألحان)** | `<audio>` عنصر أصلي + مشغّل مخصّص (wavesurfer.js اختياري، محلي) | تشغيل الألحان بدون خدمة خارجية |
| **الإيميلات** | **Nodemailer + SMTP** (STRATO أو أي مزوّد) | تحكّم كامل، بدون API طرف ثالث |
| **قوالب الإيميل** | **React Email** | قوالب بمكوّنات React، متسقة مع باقي الستاك |
| **الاحصائيات** | **عدّادات داخلية بسيطة** أولاً (Umami self-hosted لاحقاً لو لزم) | أخف، خصوصية أعلى، بدون خدمة إضافية للإطلاق الأول |
| **الفاليديشن** | **Zod** | تحقّق من الفورمات والـ API، متكامل مع Prisma/TS |
| **الفورمات** | **React Hook Form + Zod** | إدارة نماذج لوحة التحكم |
| **النشر** | Docker + Traefik + Makefile — *موجود* | نفس المنظومة الحالية، بنضيف عليها Postgres |
---
## ليش Next.js فل ستاك (مش باك اند منفصل)؟
- مشروع واحد = صيانة أسهل وتوثيق أوضح للـ agents.
- **Server Actions** بتلغي الحاجة لطبقة API منفصلة لمعظم عمليات لوحة التحكم.
- **Route Handlers** (`app/api/...`) لأي endpoints عامة (مثلاً webhook، رفع، RSS للألحان).
- كل شي TypeScript من الداتابيس للواجهة = نوع واحد يمشي من `Prisma` لـ `React`.
## ليش PostgreSQL + Prisma؟
- بياناتك **علائقية**: عمل واحد ينتمي لتصنيف، تطبيق إله كذا سكرين شوت، لحن إله تصنيف (عربي/شرقي). Postgres مثالي لهيك.
- Prisma بيخلّي `schema.prisma` **مصدر الحقيقة الوحيد** للبنية — وهاد بيخدم شرط التوثيق.
- migrations منظّمة → تشتغل نفسها محلياً وعلى السيرفر.
## مبدأ "كل المكتبات محليّة"
- **shadcn/ui**: بتتنسخ داخل `components/ui/` — مش dependency خارجي، تحت سيطرتك بالكامل.
- الخطوط: self-hosted عبر `next/font` (بدون Google Fonts CDN).
- الأيقونات: `lucide-react` (bundled محلياً).
- مشغّل الصوت والاحصائيات: self-hosted على نفس الـ VPS.
- تجنّب أي CDN وقت التشغيل — كل شي يتحزّم مع الـ build.
---
## حزم مقترحة للتثبيت (مرجع)
```
# UI
tailwindcss postcss autoprefixer
# shadcn/ui يُهيّأ عبر: npx shadcn@latest init
class-variance-authority clsx tailwind-merge lucide-react tailwindcss-animate
# DB
prisma @prisma/client
# Auth
next-auth@beta
# Forms & validation
react-hook-form zod @hookform/resolvers
# Email
nodemailer @react-email/components @react-email/render
# Media
sharp
# (اختياري) مشغّل صوت
wavesurfer.js
```
> ⚠️ لا تثبّت شي إلا وقت الحاجة الفعلية للـ feature المرتبط فيه، وحدّث `PROJECT-STATE.md` بعد كل إضافة.
+54
View File
@@ -0,0 +1,54 @@
# 📋 قائمة المهام — diyaa.de
> لما تسأل الـ agent "شو عنا شي نعملو؟" → بيقرأ من قسم **🔵 مفتوحة** ويقترح المهمة الجاهزة للبدء (يلي مو محجوبة بمهمة قبلها).
> لما تخلص مهمة → **انقلها** من "🔵 مفتوحة" لـ "✅ منجزة (Ready)" مع تاريخ، وحدّث `PROJECT-STATE.md`.
**نظام الحالات:** `🔵 مفتوحة` · `🟡 قيد التنفيذ` · `✅ منجزة (Ready)` · `⛔ محجوبة (تحتاج مهمة قبلها)`
---
## 🟡 قيد التنفيذ
_(لا يوجد حالياً)_
---
## 🔵 مفتوحة (مرتّبة بالأولوية — ابدأ من فوق)
> 🆕 **قرارات مراجعة 2026-08-05** أضافت T-16 و T-17 وعدّلت T-08→T-13. اقرأ `REVIEW-2026-08-05.md`.
### T-14 · وضع الصيانة
وضع صيانة منفصل عن coming-soon + رسالة + استثناء الأدمن.
**الحالة:** 🔵
### T-15 · تحديث Makefile + docker-compose للداتابيس
أوامر `db-up`, `migrate`, `seed`, `studio` + volumes (بيانات + ملفات) + تطبيق migrations عند النشر + backup موثّق.
**الحالة:** 🔵
---
## ✅ منجزة (Ready)
- **2026-08-05 · T-13 · الإحصائيات الداخلية** — إضافة أحداث الزيارات وفتح المشاريع وتشغيل الألحان ونقر الروابط ورسائل التواصل، endpoint محمي بالتحقق وتحديد المعدل، وواجهة ملخص داخل الأدمن لآخر 30 يوماً. ✅
- **2026-08-05 · T-12 · الصفحات الرسمية** — إضافة سياسة الخصوصية وشروط الاستخدام وImpressum بالعربي والإنجليزي، ربطها بالفوتر، وإضافة حقول الهوية القانونية إلى `.env.example`. ✅
- **2026-08-05 · T-11 · الإيميلات + SMTP** — ربط نموذج التواصل بحفظ الرسائل وإرسال إشعار للمالك وتأكيد للمرسل عبر Nodemailer، قوالب React Email وبديل نصي، حماية Honeypot وRate limiting، وإضافة متغيّرات SMTP إلى `.env.example`. ✅
- **2026-08-05 · T-10 · الألحان (باك + فرونت + مشغّل)** — إضافة CRUD كامل للألحان مع التصنيفات ورفع الصوت والغلاف والتحكم بالتحميل، قسم عام بصفحات قائمة وتفاصيل، فلترة التصنيفات، ومشغّل صوت reusable بالتشغيل والتقديم والتأخير ومستوى الصوت والمدة والتحميل الاختياري. ✅
- **2026-08-05 · T-09 · التطبيقات (type=APP)** — إضافة حقول iOS إلى CRUD المشاريع، فلتر التطبيقات في الأدمن، وقسم عام للتطبيقات مع معرض السكرين شوتس وروابط المتاجر والدعم والخصوصية. ✅
- **2026-08-05 · T-08 · المشاريع: أعمال + مواقع + تصاميم** — إضافة CRUD أساسي للمشاريع، رفع الصور من النموذج، صفحات الأعمال والمواقع والتصاميم مع فلترة التصنيفات وصفحات تفاصيل حسب slug. ✅
- **2026-08-05 · T-16 · إعادة تشكيل المخطط للخيار A** — استبدال Work/App بـ Project موحّد، تحديث CategoryKind إلى PROJECT/MELODY، إضافة حقول المشاريع وحقول iOS، وتطبيق migration محلية ناجحة. ✅
- **2026-08-05 · T-17 · إصلاح `.env.example` + وضع الصيانة** — تصحيح قيمة `full` وإضافة `maintenance` إلى أوضاع الموقع مع إبقاء واجهة الصيانة ضمن T-14. ✅
- **2026-08-05 · T-07 · رفع الملفات (Media)** — إضافة تخزين دائم للملفات، معالجة الصور إلى WebP عبر sharp، حفظ الصوت بصيغته، endpoint محمي، ومكوّن رفع reusable. ✅
- **2026-08-05 · T-06 · التصنيفات (Categories) CRUD** — إضافة تحقق Zod وعمليات إنشاء/تعديل/حذف للتصنيفات من لوحة الأدمن مع دعم PROJECT / MELODY. ✅
- **2026-08-05 · T-05 · Layout لوحة التحكم** — إضافة shell محمي، سايدبار، ترويسة مشتركة، وفورم عام مبني على React Hook Form وZod. ✅
- **2026-08-05 · T-04 · مصادقة الأدمن** — إضافة Auth.js Credentials، تشفير كلمات السر، حماية `app/admin/`، صفحة دخول، وseed آمن لمستخدم الأدمن. ✅
- **2026-08-05 · T-03 · إعداد PostgreSQL + Prisma** — إضافة خدمة PostgreSQL مع volume وشبكة داخلية، مخطط Prisma الأولي، migration `init`، وعميل قاعدة البيانات singleton. ✅
- **2026-08-05 · T-02 · تهيئة shadcn/ui** — إضافة `components.json`، أداة `cn`، والمكوّنات الأساسية Button/Card/Input/Dialog/Select/Form مع دعم RTL والثيم الحالي. ✅
- **2026-08-05 · T-01 · تهيئة Tailwind CSS** — إضافة إعداد Tailwind/PostCSS، وربط tokens الثيم الحالية مع الحفاظ على الوضعين الفاتح والغامق. ✅
- **2026-08-03 · T-00 · منظومة التوثيق** — إنشاء `docs/` (STATE, STACK, ARCHITECTURE, CHECKLIST, TODO, IDEAS) + `AGENTS.md`. موثّق بالكامل. ✅
---
### تعليمات للـ agent
1. اقترح **أول مهمة 🔵 مفتوحة وغير محجوبة** من الأعلى.
2. عند البدء: غيّر حالتها لـ `🟡 قيد التنفيذ`.
3. عند الانتهاء: انقلها لـ **✅ منجزة** مع التاريخ ورقم المهمة، حرّر حالات المهام المحجوبة يلي انفتحت، وحدّث `PROJECT-STATE.md`.
+8
View File
@@ -0,0 +1,8 @@
export function trackClientEvent(input: { type: "PAGE_VIEW" | "PROJECT_OPEN" | "MELODY_PLAY" | "PROJECT_LINK_CLICK"; path?: string; entityId?: string }) {
void fetch("/api/analytics", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...input, path: input.path || window.location.pathname }),
keepalive: true,
}).catch(() => undefined);
}
+41
View File
@@ -0,0 +1,41 @@
import { prisma } from "@/lib/db";
import { analyticsEventSchema, type AnalyticsEventInput } from "@/lib/validations/analytics";
export async function recordAnalyticsEvent(input: unknown) {
const parsed = analyticsEventSchema.safeParse(input);
if (!parsed.success) return false;
const { type, path, entityId } = parsed.data;
await prisma.analyticsEvent.create({
data: {
type,
path: path || null,
projectId: type === "PROJECT_OPEN" || type === "PROJECT_LINK_CLICK" ? entityId || null : null,
melodyId: type === "MELODY_PLAY" ? entityId || null : null,
},
});
return true;
}
export async function getAnalyticsSummary() {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [total, grouped, recent] = await Promise.all([
prisma.analyticsEvent.count(),
prisma.analyticsEvent.groupBy({
by: ["type"],
where: { createdAt: { gte: since } },
_count: { _all: true },
orderBy: { _count: { type: "desc" } },
}),
prisma.analyticsEvent.findMany({
where: { createdAt: { gte: since } },
orderBy: { createdAt: "desc" },
take: 12,
select: { id: true, type: true, path: true, createdAt: true },
}),
]);
return { total, since, grouped, recent };
}
export type { AnalyticsEventInput };
+22
View File
@@ -0,0 +1,22 @@
import type { NextAuthConfig } from "next-auth";
const authConfig = {
providers: [],
pages: {
signIn: "/admin/login",
},
session: {
strategy: "jwt",
},
callbacks: {
authorized({ auth, request }) {
if (request.nextUrl.pathname === "/admin/login") {
return true;
}
return Boolean(auth?.user);
},
},
} satisfies NextAuthConfig;
export default authConfig;
+39
View File
@@ -0,0 +1,39 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { prisma } from "@/lib/db";
import authConfig from "@/lib/auth.config";
import { loginSchema } from "@/lib/validations/auth";
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const parsed = loginSchema.safeParse(credentials);
if (!parsed.success) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: parsed.data.email },
});
if (!user || !(await compare(parsed.data.password, user.password))) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.email,
};
},
}),
],
});
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
export const db = prisma;
+55
View File
@@ -0,0 +1,55 @@
import nodemailer from "nodemailer";
import { render } from "@react-email/render";
import ContactMessageEmail, { ContactConfirmationEmail } from "@/components/emails/contact-message";
type ContactEmailData = { name: string; email: string; message: string };
function getTransporter() {
const port = Number(process.env.SMTP_PORT || 587);
return nodemailer.createTransport({
host: process.env.SMTP_HOST,
port,
secure: process.env.SMTP_SECURE === "true" || port === 465,
auth: process.env.SMTP_USER && process.env.SMTP_PASSWORD
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }
: undefined,
});
}
function getEmailConfig() {
const from = process.env.SMTP_FROM;
const to = process.env.SMTP_TO || process.env.NEXT_PUBLIC_CONTACT_EMAIL;
if (!process.env.SMTP_HOST || !from || !to) throw new Error("SMTP email configuration is incomplete.");
return { from, to };
}
function getPlainText({ name, email, message }: ContactEmailData) {
return [`New contact message`, ``, `Name: ${name}`, `Email: ${email}`, ``, message].join("\n");
}
function getConfirmationText(name: string, siteUrl: string) {
return [`Hi ${name},`, ``, `Your message was received. I will get back to you as soon as possible.`, ``, siteUrl].join("\n");
}
export async function sendContactMessageEmail(data: ContactEmailData) {
const { from, to } = getEmailConfig();
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000";
const html = await render(ContactMessageEmail({ ...data, siteUrl }));
const transporter = getTransporter();
await transporter.sendMail({
from,
to,
replyTo: data.email,
subject: `New contact message from ${data.name}`,
text: getPlainText(data),
html,
});
const confirmationHtml = await render(ContactConfirmationEmail({ name: data.name, siteUrl }));
await transporter.sendMail({
from,
to: data.email,
subject: "Thanks for contacting Diyaa",
text: getConfirmationText(data.name, siteUrl),
html: confirmationHtml,
});
}
+3 -3
View File
@@ -1,7 +1,7 @@
import ar from "@/content/ar"; import ar from "@/content/ar";
import en from "@/content/en"; import en from "@/content/en";
import type { Dictionary } from "@/content/types"; import type { Dictionary } from "@/content/types";
import { isComingSoonMode } from "@/lib/site"; import { isSiteClosedMode } from "@/lib/site";
export const locales = ["ar", "en"] as const; export const locales = ["ar", "en"] as const;
@@ -13,11 +13,11 @@ const dictionaries: Record<Locale, Dictionary> = {
}; };
export function getActiveLocale(locale: Locale): Locale { export function getActiveLocale(locale: Locale): Locale {
return isComingSoonMode() ? "en" : locale; return isSiteClosedMode() ? "en" : locale;
} }
export function getEnabledLocales(): Locale[] { export function getEnabledLocales(): Locale[] {
return isComingSoonMode() ? ["en"] : [...locales]; return isSiteClosedMode() ? ["en"] : [...locales];
} }
export function isLocale(value: string): value is Locale { export function isLocale(value: string): value is Locale {
+102
View File
@@ -0,0 +1,102 @@
import type { Locale } from "@/lib/i18n";
import { legalConfig, siteConfig } from "@/lib/site";
export type LegalSection = { heading: string; paragraphs?: string[]; list?: string[] };
export type LegalDocument = { title: string; intro: string; updatedLabel: string; sections: LegalSection[] };
export type LegalPage = "privacy" | "terms" | "impressum";
function configured(value: string, fallback: string) {
return value || fallback;
}
export function getLegalDocument(locale: Locale, page: LegalPage): LegalDocument {
const isArabic = locale === "ar";
const email = configured(siteConfig.email, isArabic ? "أضف البريد في ملف البيئة" : "Configure the contact email in the environment");
const address = [legalConfig.address, [legalConfig.postalCode, legalConfig.city].filter(Boolean).join(" "), legalConfig.country].filter(Boolean);
const addressText = configured(address.join(", "), isArabic ? "أضف العنوان القانوني في ملف البيئة" : "Configure the legal address in the environment");
const owner = legalConfig.name;
const lastUpdated = "2026-08-05";
const documents: Record<Locale, Record<LegalPage, LegalDocument>> = {
ar: {
privacy: {
title: "سياسة الخصوصية",
intro: "توضح هذه الصفحة كيف يتعامل موقع ضياء مع البيانات التي تُرسل من خلال نموذج التواصل والبيانات التقنية اللازمة لتشغيل الموقع.",
updatedLabel: `آخر تحديث: ${lastUpdated}`,
sections: [
{ heading: "المسؤول عن المعالجة", paragraphs: [`${owner}، ${addressText}`, `البريد الإلكتروني: ${email}`] },
{ heading: "البيانات التي نجمعها", paragraphs: ["عند استخدام نموذج التواصل قد نعالج الاسم والبريد الإلكتروني ومحتوى الرسالة. قد يعالج الخادم أيضاً بيانات تقنية ضرورية لتقديم الموقع وحمايته من إساءة الاستخدام."] },
{ heading: "الغرض والأساس القانوني", paragraphs: ["نستخدم بيانات نموذج التواصل للرد على الاستفسارات وتنفيذ الخطوات المطلوبة قبل التعاقد عند الاقتضاء. يكون الأساس القانوني عادةً تنفيذ طلبك أو مصلحتنا المشروعة في إدارة التواصل المهني، بحسب طبيعة الرسالة."] },
{ heading: "الإرسال والتخزين", paragraphs: ["تُحفظ رسائل التواصل في قاعدة بيانات الموقع، ويُرسل إشعار بها عبر مزود SMTP مضبوط في إعدادات الخادم. لا نبيع البيانات ولا نستخدمها للإعلانات، ونحتفظ بها فقط للمدة اللازمة لمعالجة الطلب والالتزامات القانونية."] },
{ heading: "الحماية من الرسائل المزعجة", paragraphs: ["يستخدم النموذج حقلاً مخفياً وتحديداً مؤقتاً لمعدل الطلبات. لا يُستخدم عنوان المصدر المحسوب للحماية كجزء من ملف تسويقي."] },
{ heading: "حقوقك", paragraphs: ["بحسب القوانين المعمول بها، قد يحق لك طلب الوصول إلى بياناتك أو تصحيحها أو حذفها أو تقييد معالجتها أو الاعتراض عليها، كما يمكنك تقديم شكوى إلى جهة حماية البيانات المختصة. تواصل معنا عبر البريد أعلاه لممارسة حق من هذه الحقوق."] },
{ heading: "التحديثات", paragraphs: ["قد نحدّث هذه السياسة عند تغيير الموقع أو طريقة معالجة البيانات. سيظهر تاريخ آخر تحديث في أعلى الصفحة."] },
],
},
terms: {
title: "شروط الاستخدام",
intro: "تحدد هذه الشروط الإطار العام لاستخدام موقع ضياء ومحتواه العام.",
updatedLabel: `آخر تحديث: ${lastUpdated}`,
sections: [
{ heading: "نطاق الاستخدام", paragraphs: ["يمكنك تصفح الموقع واستخدام محتواه لأغراض شخصية أو مهنية مشروعة. لا يجوز استخدام الموقع بطريقة تضر به أو تتجاوز الضوابط التقنية أو تنتهك حقوق الآخرين."] },
{ heading: "المحتوى وحقوق الملكية", paragraphs: ["ما لم يُذكر خلاف ذلك، تعود حقوق النصوص والصور والتصاميم والمشاريع المعروضة إلى مالكها أو أصحاب الحقوق المعنيين. لا يجوز نسخ المحتوى أو إعادة نشره أو استخدامه تجارياً دون إذن مناسب."] },
{ heading: "التوفر والروابط الخارجية", paragraphs: ["نسعى إلى إبقاء الموقع متاحاً ودقيقاً، لكن لا نضمن التوفر الدائم أو خلو المحتوى من الأخطاء. قد يحتوي الموقع على روابط لخدمات خارجية؛ تقع مسؤولية استخدام تلك الخدمات على مشغليها."] },
{ heading: "التواصل", paragraphs: [`للاستفسارات حول هذه الشروط تواصل مع ${owner} عبر ${email}.`] },
{ heading: "التعديلات", paragraphs: ["يجوز تحديث هذه الشروط عند تطور الموقع أو الخدمات. تسري النسخة المنشورة على الاستخدام اللاحق للموقع."] },
],
},
impressum: {
title: "Impressum · بيانات المزوّد",
intro: "بيانات المزوّد المطلوبة للموقع المهني. يجب إكمال الحقول من ملف البيئة قبل النشر العام.",
updatedLabel: `آخر تحديث: ${lastUpdated}`,
sections: [
{ heading: "المزوّد", paragraphs: [owner, addressText] },
{ heading: "التواصل المباشر", paragraphs: [`البريد الإلكتروني: ${email}`] },
{ heading: "بيانات إضافية", paragraphs: [legalConfig.vatId ? `رقم ضريبة القيمة المضافة: ${legalConfig.vatId}` : "لم يتم ضبط رقم ضريبة القيمة المضافة في ملف البيئة."] },
{ heading: "تنبيه الإكمال", paragraphs: ["تحقق من الاسم والعنوان والبيانات المهنية الفعلية مع مستشارك القانوني قبل إطلاق الموقع. هذه الصفحة قالب تقني وليست استشارة قانونية."] },
],
},
},
en: {
privacy: {
title: "Privacy policy",
intro: "This page explains how Diyaa handles data submitted through the contact form and the technical data needed to operate the website.",
updatedLabel: `Last updated: ${lastUpdated}`,
sections: [
{ heading: "Controller", paragraphs: [`${owner}, ${addressText}`, `Email: ${email}`] },
{ heading: "Data we collect", paragraphs: ["When you use the contact form, we may process your name, email address, and message. The server may also process technical data necessary to deliver the site and prevent abuse."] },
{ heading: "Purpose and legal basis", paragraphs: ["We use contact-form data to respond to inquiries and, where applicable, take steps at your request before entering into a contract. The legal basis depends on the nature of the inquiry and may be contract-related processing or our legitimate interest in professional communication."] },
{ heading: "Delivery and storage", paragraphs: ["Contact messages are stored in the website database and an email notification is sent through the SMTP provider configured on the server. We do not sell the data or use it for advertising, and retain it only as long as needed to handle the inquiry and meet legal obligations."] },
{ heading: "Spam protection", paragraphs: ["The form uses a hidden honeypot field and temporary request throttling. The source address used for this protection is not used as a marketing profile."] },
{ heading: "Your rights", paragraphs: ["Depending on applicable law, you may have rights to access, correct, delete, restrict, or object to processing of your data, and to complain to a competent data-protection authority. Contact us at the email above to exercise a right."] },
{ heading: "Updates", paragraphs: ["We may update this policy when the website or its processing practices change. The latest update date is shown above."] },
],
},
terms: {
title: "Terms of use",
intro: "These terms provide the general framework for using the Diyaa website and its public content.",
updatedLabel: `Last updated: ${lastUpdated}`,
sections: [
{ heading: "Permitted use", paragraphs: ["You may browse and use the website for lawful personal or professional purposes. You must not use it in a way that harms the site, bypasses technical safeguards, or infringes the rights of others."] },
{ heading: "Content and intellectual property", paragraphs: ["Unless stated otherwise, texts, images, designs, and showcased projects belong to their owner or the respective rights holders. Do not copy, republish, or commercially use content without appropriate permission."] },
{ heading: "Availability and external links", paragraphs: ["We aim to keep the website available and accurate, but do not guarantee uninterrupted availability or error-free content. External links lead to services operated by third parties, whose operators are responsible for their own services."] },
{ heading: "Contact", paragraphs: [`For questions about these terms, contact ${owner} at ${email}.`] },
{ heading: "Changes", paragraphs: ["These terms may be updated as the website or services evolve. The published version applies to future use of the website."] },
],
},
impressum: {
title: "Legal notice",
intro: "Provider information for this professional website. Complete the environment fields before public launch.",
updatedLabel: `Last updated: ${lastUpdated}`,
sections: [
{ heading: "Provider", paragraphs: [owner, addressText] },
{ heading: "Direct contact", paragraphs: [`Email: ${email}`] },
{ heading: "Additional information", paragraphs: [legalConfig.vatId ? `VAT ID: ${legalConfig.vatId}` : "No VAT ID has been configured in the environment."] },
{ heading: "Completion notice", paragraphs: ["Verify the actual name, address, and professional details with your legal adviser before launching the website. This page is a technical template, not legal advice."] },
],
},
},
};
return documents[locale][page];
}
+36
View File
@@ -0,0 +1,36 @@
import type { Locale } from "@/lib/i18n";
const directoryCopy = {
ar: {
segment: "melodies",
eyebrow: "الألحان",
title: "ألحان ومقاطع صوتية مختارة",
description: "استمع إلى مجموعة من الألحان والمقاطع الصوتية المنشورة.",
empty: "لا توجد ألحان منشورة ضمن هذا التصنيف بعد.",
filterLabel: "فلترة الألحان حسب التصنيف",
all: "الكل",
play: "تشغيل",
download: "تحميل الصوت",
back: "العودة إلى الألحان",
},
en: {
segment: "melodies",
eyebrow: "Melodies",
title: "Selected melodies and audio pieces",
description: "Listen to a collection of published melodies and audio pieces.",
empty: "No published melodies are available in this category yet.",
filterLabel: "Filter melodies by category",
all: "All",
play: "Play",
download: "Download audio",
back: "Back to melodies",
},
} as const;
export function getMelodyDirectoryCopy(locale: Locale) {
return directoryCopy[locale];
}
export function getMelodyPath(locale: Locale, slug?: string) {
return `/${locale}/melodies${slug ? `/${slug}` : ""}`;
}
+27
View File
@@ -0,0 +1,27 @@
import { prisma } from "@/lib/db";
export async function getPublishedMelodies(categorySlug?: string) {
return prisma.melody.findMany({
where: {
status: "PUBLISHED",
category: categorySlug ? { slug: categorySlug, kind: "MELODY" } : undefined,
},
orderBy: [{ isFeatured: "desc" }, { sortOrder: "asc" }, { createdAt: "desc" }, { titleEn: "asc" }],
include: { category: { select: { slug: true, nameAr: true, nameEn: true } } },
});
}
export async function getMelodyCategories() {
return prisma.category.findMany({
where: { kind: "MELODY" },
orderBy: [{ order: "asc" }, { nameEn: "asc" }],
select: { id: true, slug: true, nameAr: true, nameEn: true, _count: { select: { melodies: true } } },
});
}
export async function getPublishedMelody(slug: string) {
return prisma.melody.findFirst({
where: { slug, status: "PUBLISHED" },
include: { category: { select: { slug: true, nameAr: true, nameEn: true } } },
});
}

Some files were not shown because too many files have changed in this diff Show More