Compare commits

..
22 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
diyaa a1749ba640 fix: add missing metadata field to dictionary translations 2026-03-13 09:44:31 +01:00
diyaa 7136a4dc3e Fix docker-compose .env 2026-03-13 09:40:14 +01:00
diyaa 877e543cf8 Fix docker-compose.yml 2026-03-13 09:37:21 +01:00
diyaa 0bb964f07e Fix Coming soon 2026-03-13 09:26:40 +01:00
128 changed files with 15374 additions and 105 deletions
+45 -6
View File
@@ -1,11 +1,50 @@
# ─── Project ───────────────────────────────────────────────
COMPOSE_PROJECT_NAME=diyaa
CONTAINER_NAME=diyaa
APP_PORT=30002
CONTAINER_NAME=diyaa-website
# ─── App ───────────────────────────────────────────────────
NODE_ENV=production
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_URL=https://your-domain.example
NEXT_PUBLIC_CONTACT_EMAIL=
NEXT_PUBLIC_LINKEDIN_URL=
NEXT_PUBLIC_GITHUB_URL=
# ─── Contact & Social ──────────────────────────────────────
NEXT_PUBLIC_CONTACT_EMAIL=work@diyaa.de
NEXT_PUBLIC_LINKEDIN_URL=https://linkedin.com/in/YOUR_USERNAME
NEXT_PUBLIC_GITHUB_URL=https://github.com/YOUR_USERNAME
# ─── Database (PostgreSQL + Prisma) ───────────────────────
# Development-only defaults. Keep real credentials in .env, never in git.
POSTGRES_DB=diyaa
POSTGRES_USER=diyaa
POSTGRES_PASSWORD=change-me
POSTGRES_PORT=55432
DATABASE_URL=postgresql://diyaa:change-me@localhost:55432/diyaa?schema=public
UPLOAD_DIR=/app/uploads
# ─── Authentication ───────────────────────────────────────
AUTH_SECRET=replace-with-a-long-random-secret
AUTH_URL=http://localhost:3000
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-a-long-admin-password
# ─── SMTP Email ────────────────────────────────────────────
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=replace-with-smtp-user
SMTP_PASSWORD=replace-with-smtp-password
SMTP_FROM=website@example.com
SMTP_TO=work@diyaa.de
# ─── Legal identity ───────────────────────────────────────
LEGAL_NAME=Diyaa
LEGAL_ADDRESS=Street and house number
LEGAL_POSTAL_CODE=00000
LEGAL_CITY=City
LEGAL_COUNTRY=Germany
# Optional when applicable.
LEGAL_VAT_ID=
+2
View File
@@ -3,5 +3,7 @@
.env.local
.env.production.local
.next
uploads
node_modules
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_GITHUB_URL=$NEXT_PUBLIC_GITHUB_URL
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
RUN mkdir -p /app/uploads && chown nextjs:nextjs /app/uploads
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
+6 -6
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
import { getActiveLocale, getDictionary, isLocale, type Locale } from "@/lib/i18n";
import { buildPageMetadata } from "@/lib/metadata";
import { isComingSoonMode } from "@/lib/site";
import { isSiteClosedMode } from "@/lib/site";
import { notFound, redirect } from "next/navigation";
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
@@ -9,7 +9,7 @@ export function generateMetadata({ params }: { params: { locale: string } }): Me
return {};
}
return buildPageMetadata(params.locale, "about");
return buildPageMetadata(getActiveLocale(params.locale), "about");
}
export default function AboutPage({ params }: { params: { locale: string } }) {
@@ -17,11 +17,11 @@ export default function AboutPage({ params }: { params: { locale: string } }) {
notFound();
}
if (isComingSoonMode()) {
redirect(`/${params.locale}`);
if (isSiteClosedMode()) {
redirect("/");
}
const locale = params.locale as Locale;
const locale = getActiveLocale(params.locale as Locale);
const dictionary = getDictionary(locale);
return (
+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" };
}
}
+21 -6
View File
@@ -1,15 +1,16 @@
import type { Metadata } from "next";
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
import { getActiveLocale, getDictionary, isLocale, type Locale } from "@/lib/i18n";
import { buildPageMetadata } from "@/lib/metadata";
import { getContactChannels, isComingSoonMode } from "@/lib/site";
import { getContactChannels, isSiteClosedMode } from "@/lib/site";
import { notFound, redirect } from "next/navigation";
import ContactForm from "@/components/public/contact-form";
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) {
return {};
}
return buildPageMetadata(params.locale, "contact");
return buildPageMetadata(getActiveLocale(params.locale), "contact");
}
export default function ContactPage({ params }: { params: { locale: string } }) {
@@ -17,11 +18,11 @@ export default function ContactPage({ params }: { params: { locale: string } })
notFound();
}
if (isComingSoonMode()) {
redirect(`/${params.locale}`);
if (isSiteClosedMode()) {
redirect("/");
}
const locale = params.locale as Locale;
const locale = getActiveLocale(params.locale as Locale);
const dictionary = getDictionary(locale);
const channels = getContactChannels(dictionary.contact);
@@ -38,6 +39,20 @@ export default function ContactPage({ params }: { params: { locale: string } })
<p>{dictionary.contact.availabilityDescription}</p>
</article>
<ContactForm
labels={{
name: dictionary.contact.formName,
email: dictionary.contact.formEmail,
message: dictionary.contact.formMessage,
submit: dictionary.contact.formSubmit,
sending: dictionary.contact.formSending,
success: dictionary.contact.formSuccess,
savedWarning: dictionary.contact.formSavedWarning,
invalid: dictionary.contact.formInvalid,
rateLimit: dictionary.contact.formRateLimit,
}}
/>
<div className="section-heading">
<h2>{dictionary.contact.channelsTitle}</h2>
</div>
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDetail from "@/components/public/project-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
import { getPublishedProject } from "@/lib/project-queries";
type DesignDetailPageProps = {
params: { locale: string; slug: string };
};
export function generateMetadata({ params }: DesignDetailPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "DESIGN");
return { title: copy.eyebrow, description: copy.description };
}
export default async function DesignDetailPage({ params }: DesignDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const project = await getPublishedProject("DESIGN", params.slug);
if (!project) notFound();
return <ProjectDetail locale={locale} type="DESIGN" project={project} />;
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProjectDirectory from "@/components/public/project-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy } from "@/lib/project-content";
type DesignsPageProps = {
params: { locale: string };
searchParams: { category?: string };
};
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const locale = getActiveLocale(params.locale as Locale);
const copy = getProjectDirectoryCopy(locale, "DESIGN");
return { title: copy.title, description: copy.description };
}
export default function DesignsPage({ params, searchParams }: DesignsPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <ProjectDirectory locale={locale} type="DESIGN" categorySlug={searchParams.category} />;
}
+11 -4
View File
@@ -1,14 +1,16 @@
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import BottomNav from "@/components/BottomNav";
import SiteFooter from "@/components/SiteFooter";
import SiteHeader from "@/components/SiteHeader";
import { getDictionary, getDirection, isLocale, locales, type Locale } from "@/lib/i18n";
import { getActiveLocale, getDictionary, getDirection, getEnabledLocales, isLocale, type Locale } from "@/lib/i18n";
import { isSiteClosedMode } from "@/lib/site";
import AnalyticsTracker from "@/components/public/analytics-tracker";
export const dynamicParams = false;
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
return getEnabledLocales().map((locale) => ({ locale }));
}
export default function LocaleLayout({
@@ -22,12 +24,17 @@ export default function LocaleLayout({
notFound();
}
const locale = params.locale as Locale;
if (isSiteClosedMode()) {
redirect("/");
}
const locale = getActiveLocale(params.locale as Locale);
const dictionary = getDictionary(locale);
return (
<div dir={getDirection(locale)} className="site-shell">
<SiteHeader locale={locale} common={dictionary.common} />
<AnalyticsTracker event="PAGE_VIEW" />
<main className="page-content">{children}</main>
<BottomNav locale={locale} common={dictionary.common} />
<SiteFooter locale={locale} common={dictionary.common} />
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import LegalDocument from "@/components/public/legal-document";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getLegalDocument } from "@/lib/legal-content";
type LegalPageProps = { params: { locale: string } };
export function generateMetadata({ params }: LegalPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const document = getLegalDocument(getActiveLocale(params.locale as Locale), "impressum");
return { title: document.title, description: document.intro };
}
export default function ImpressumPage({ params }: LegalPageProps) {
if (!isLocale(params.locale)) notFound();
return <LegalDocument document={getLegalDocument(getActiveLocale(params.locale as Locale), "impressum")} />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import LegalDocument from "@/components/public/legal-document";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getLegalDocument } from "@/lib/legal-content";
type LegalPageProps = { params: { locale: string } };
export function generateMetadata({ params }: LegalPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const document = getLegalDocument(getActiveLocale(params.locale as Locale), "privacy");
return { title: document.title, description: document.intro };
}
export default function PrivacyPage({ params }: LegalPageProps) {
if (!isLocale(params.locale)) notFound();
return <LegalDocument document={getLegalDocument(getActiveLocale(params.locale as Locale), "privacy")} />;
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import LegalDocument from "@/components/public/legal-document";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getLegalDocument } from "@/lib/legal-content";
type LegalPageProps = { params: { locale: string } };
export function generateMetadata({ params }: LegalPageProps): Metadata {
if (!isLocale(params.locale)) return {};
const document = getLegalDocument(getActiveLocale(params.locale as Locale), "terms");
return { title: document.title, description: document.intro };
}
export default function TermsPage({ params }: LegalPageProps) {
if (!isLocale(params.locale)) notFound();
return <LegalDocument document={getLegalDocument(getActiveLocale(params.locale as Locale), "terms")} />;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import MelodyDetail from "@/components/public/melody-detail";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getPublishedMelody } from "@/lib/melody-queries";
type MelodyDetailPageProps = { params: { locale: string; slug: string } };
export async function generateMetadata({ params }: MelodyDetailPageProps): Promise<Metadata> {
if (!isLocale(params.locale)) return {};
const melody = await getPublishedMelody(params.slug);
if (!melody) return {};
return { title: getActiveLocale(params.locale as Locale) === "ar" ? melody.titleAr : melody.titleEn };
}
export default async function MelodyDetailPage({ params }: MelodyDetailPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
const melody = await getPublishedMelody(params.slug);
if (!melody) notFound();
return <MelodyDetail locale={locale} melody={melody} />;
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import MelodyDirectory from "@/components/public/melody-directory";
import { getActiveLocale, isLocale, type Locale } from "@/lib/i18n";
import { getMelodyDirectoryCopy } from "@/lib/melody-content";
type MelodiesPageProps = { params: { locale: string }; searchParams: { category?: string } };
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
if (!isLocale(params.locale)) return {};
const copy = getMelodyDirectoryCopy(getActiveLocale(params.locale as Locale));
return { title: copy.title, description: copy.description };
}
export default function MelodiesPage({ params, searchParams }: MelodiesPageProps) {
if (!isLocale(params.locale)) notFound();
const locale = getActiveLocale(params.locale as Locale);
return <MelodyDirectory locale={locale} categorySlug={searchParams.category} />;
}
+3 -3
View File
@@ -1,6 +1,6 @@
import type { Metadata } from "next";
import HeroSection from "@/components/HeroSection";
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
import { getActiveLocale, getDictionary, isLocale, type Locale } from "@/lib/i18n";
import { buildPageMetadata } from "@/lib/metadata";
import { notFound } from "next/navigation";
@@ -9,7 +9,7 @@ export function generateMetadata({ params }: { params: { locale: string } }): Me
return {};
}
return buildPageMetadata(params.locale, "home");
return buildPageMetadata(getActiveLocale(params.locale), "home");
}
export default function HomePage({ params }: { params: { locale: string } }) {
@@ -17,7 +17,7 @@ export default function HomePage({ params }: { params: { locale: string } }) {
notFound();
}
const locale = params.locale as Locale;
const locale = getActiveLocale(params.locale as Locale);
const dictionary = getDictionary(locale);
return <HeroSection locale={locale} home={dictionary.home} />;
+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 });
}
}
+104
View File
@@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg: #07111f;
--surface: rgba(11, 20, 36, 0.84);
@@ -200,6 +204,22 @@ a {
animation: fade-up 460ms ease both;
}
.coming-soon-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
}
.coming-soon-title {
margin: 0;
font-size: clamp(2.5rem, 10vw, 5.5rem);
font-weight: 800;
line-height: 1;
letter-spacing: -0.05em;
text-align: center;
}
.panel {
background: var(--surface);
border: 1px solid var(--line-strong);
@@ -305,6 +325,72 @@ a {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.coming-soon {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.8fr);
gap: 1.4rem;
align-items: stretch;
min-height: min(72vh, 760px);
padding: clamp(1.5rem, 3vw, 2.4rem);
}
.coming-soon::before {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(circle at 18% 20%, color-mix(in srgb, var(--brand) 18%, transparent), transparent 28%),
radial-gradient(circle at 86% 18%, color-mix(in srgb, var(--brand-2) 18%, transparent), transparent 24%),
linear-gradient(135deg, color-mix(in srgb, var(--surface-2) 42%, transparent), transparent 66%);
pointer-events: none;
}
.coming-soon-copy,
.coming-soon-meta {
position: relative;
z-index: 1;
}
.coming-soon-copy {
display: grid;
align-content: center;
gap: 0.25rem;
}
.coming-soon-copy .cta-row {
margin-top: 1.65rem;
}
.coming-soon-meta {
display: grid;
gap: 0.9rem;
align-content: end;
}
.coming-soon-card {
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--line-strong) 92%, transparent);
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
backdrop-filter: blur(16px);
padding: 1.1rem 1rem;
}
.coming-soon-value {
display: block;
font-size: 1rem;
font-weight: 800;
color: var(--text);
}
.coming-soon-label {
display: block;
margin-top: 0.28rem;
color: var(--muted);
font-size: 0.9rem;
}
.split-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -531,6 +617,24 @@ a {
}
.split-grid,
.stat-grid,
.coming-soon {
grid-template-columns: 1fr;
}
.coming-soon {
min-height: auto;
}
.coming-soon-meta,
.coming-soon-copy {
align-content: start;
}
.coming-soon-meta {
gap: 0.8rem;
}
.stat-grid {
grid-template-columns: 1fr;
}
+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

+25 -8
View File
@@ -1,31 +1,45 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { siteConfig } from "@/lib/site";
import { isSiteClosedMode, siteConfig } from "@/lib/site";
import "./globals.css";
export const metadata: Metadata = {
export function generateMetadata(): Metadata {
const siteClosed = isSiteClosedMode();
return {
metadataBase: new URL(siteConfig.siteUrl),
title: {
default: "Diyaa",
template: "%s | Diyaa",
},
description: "Bilingual professional website built for private-server deployment.",
description: siteClosed
? "Minimal coming soon page for the upcoming launch."
: "Bilingual professional website built for private-server deployment.",
applicationName: "Diyaa",
authors: [{ name: "Diyaa" }],
creator: "Diyaa",
publisher: "Diyaa",
alternates: {
languages: {
languages: siteClosed
? {
en: "/",
"x-default": "/",
}
: {
ar: "/ar",
en: "/en",
"x-default": "/ar",
},
},
};
}
const themeScript = `
function getThemeScript() {
const siteClosed = isSiteClosedMode();
return `
(() => {
const locale = window.location.pathname.split("/").filter(Boolean)[0] === "en" ? "en" : "ar";
const locale = ${siteClosed ? '"en"' : 'window.location.pathname.split("/").filter(Boolean)[0] === "en" ? "en" : "ar"'};
const direction = locale === "ar" ? "rtl" : "ltr";
document.documentElement.lang = locale;
document.documentElement.dir = direction;
@@ -38,12 +52,15 @@ const themeScript = `
}
})();
`;
}
export default function RootLayout({ children }: { children: ReactNode }) {
const siteClosed = isSiteClosedMode();
return (
<html lang="ar" dir="rtl" data-theme="dark" suppressHydrationWarning>
<html lang={siteClosed ? "en" : "ar"} dir={siteClosed ? "ltr" : "rtl"} data-theme="dark" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
<script dangerouslySetInnerHTML={{ __html: getThemeScript() }} />
</head>
<body>{children}</body>
</html>
+14 -1
View File
@@ -1,6 +1,9 @@
import Link from "next/link";
import { isSiteClosedMode } from "@/lib/site";
export default function NotFound() {
const siteClosed = isSiteClosedMode();
return (
<main className="page-content">
<section className="panel section-stack">
@@ -8,17 +11,27 @@ export default function NotFound() {
<p className="eyebrow">404</p>
<h1>الصفحة غير موجودة</h1>
<p className="lead">
الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى النسخة العربية أو الإنجليزية من الصفحة الرئيسية.
{siteClosed
? "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى الصفحة الرئيسية."
: "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى النسخة العربية أو الإنجليزية من الصفحة الرئيسية."}
</p>
</div>
<div className="cta-row">
{siteClosed ? (
<Link href="/" className="cta-btn">
العودة إلى الرئيسية
</Link>
) : (
<>
<Link href="/ar" className="cta-btn">
العودة إلى العربية
</Link>
<Link href="/en" className="ghost-btn">
Go to English
</Link>
</>
)}
</div>
</section>
</main>
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

+9
View File
@@ -1,5 +1,14 @@
import { redirect } from "next/navigation";
import { isSiteClosedMode } from "@/lib/site";
export default function RootPage() {
if (isSiteClosedMode()) {
return (
<main className="coming-soon-page">
<h1 className="coming-soon-title">Coming Soon123</h1>
</main>
);
}
redirect("/ar");
}
+7 -3
View File
@@ -1,11 +1,15 @@
import type { MetadataRoute } from "next";
import { getLocalizedPath, siteConfig } from "@/lib/site";
import { getLocalizedPath, isSiteClosedMode, siteConfig } from "@/lib/site";
import type { Locale } from "@/lib/i18n";
const pages = ["", "/about", "/contact"] as const;
const locales = ["ar", "en"] as const;
const allPages = ["", "/about", "/contact", "/legal/privacy", "/legal/terms", "/legal/impressum"] as const;
const allLocales = ["ar", "en"] as const;
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
const siteClosed = isSiteClosedMode();
const pages: readonly (typeof allPages)[number][] = siteClosed ? [] : allPages;
const locales: readonly Locale[] = siteClosed ? [] : allLocales;
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"
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { HomeVariantContent } from "@/content/types";
import { getEmailHref } from "@/lib/site";
type ComingSoonPanelProps = {
content: HomeVariantContent;
};
export default function ComingSoonPanel({ content }: ComingSoonPanelProps) {
const emailHref = getEmailHref();
return (
<section className="coming-soon panel">
<div className="coming-soon-copy">
<p className="availability-badge">{content.badge}</p>
<p className="eyebrow">{content.kicker}</p>
<h1>{content.title}</h1>
<p className="lead">{content.description}</p>
<div className="cta-row">
{emailHref ? (
<a href={emailHref} className="cta-btn">
{content.primaryCta}
</a>
) : (
<span className="cta-btn is-disabled" aria-disabled="true">
{content.primaryCta}
</span>
)}
</div>
</div>
<div className="coming-soon-meta" aria-label="Launch status">
{content.highlights.map((item) => (
<article key={item.label} className="coming-soon-card">
<span className="coming-soon-value">{item.value}</span>
<span className="coming-soon-label">{item.label}</span>
</article>
))}
</div>
</section>
);
}
+7 -1
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import ComingSoonPanel from "@/components/ComingSoonPanel";
import type { HomeContent, HomeVariantContent } from "@/content/types";
import type { Locale } from "@/lib/i18n";
import { getEmailHref, getModeValue, isComingSoonMode } from "@/lib/site";
@@ -10,8 +11,13 @@ type HeroSectionProps = {
export default function HeroSection({ locale, home }: HeroSectionProps) {
const activeHome: HomeVariantContent = getModeValue(home);
const comingSoon = isComingSoonMode();
const emailHref = getEmailHref();
if (comingSoon) {
return <ComingSoonPanel content={activeHome} />;
}
return (
<section className="hero panel">
<p className="availability-badge">{activeHome.badge}</p>
@@ -29,7 +35,7 @@ export default function HeroSection({ locale, home }: HeroSectionProps) {
{activeHome.primaryCta}
</span>
)}
<Link href={isComingSoonMode() ? `/${locale}` : `/${locale}/about`} className="ghost-btn">
<Link href={`/${locale}/about`} className="ghost-btn">
{activeHome.secondaryCta}
</Link>
</div>
+12 -1
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import type { Locale } from "@/lib/i18n";
import type { CommonContent } from "@/content/types";
import { getModeValue } from "@/lib/site";
import { getModeValue, isComingSoonMode } from "@/lib/site";
type SiteFooterProps = {
locale: Locale;
@@ -10,6 +11,7 @@ type SiteFooterProps = {
export default function SiteFooter({ locale, common }: SiteFooterProps) {
const year = new Date().getFullYear();
const commonVariant = getModeValue(common.variants);
const isComingSoon = isComingSoonMode();
return (
<footer className="site-footer">
@@ -18,8 +20,17 @@ export default function SiteFooter({ locale, common }: SiteFooterProps) {
<p>{common.footerRights.replace("{year}", String(year))}</p>
<p>{commonVariant.footerBuiltWith}</p>
</div>
{!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>
</footer>
);
}
+4 -1
View File
@@ -27,13 +27,16 @@ export default function SiteHeader({ locale, common }: SiteHeaderProps) {
{!isComingSoon ? (
<nav className="header-nav" aria-label={common.navLabel}>
<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}/contact`}>{common.nav.contact}</Link>
</nav>
) : null}
<div className="header-actions">
<LanguageSwitcher locale={locale} label={common.languageSwitcherLabel} />
{!isComingSoon ? <LanguageSwitcher locale={locale} label={common.languageSwitcherLabel} /> : null}
<ThemeToggle
label={common.themeToggleLabel}
lightLabel={common.themeLight}
+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: "داكن",
nav: {
home: "الرئيسية",
projects: "المشاريع",
apps: "التطبيقات",
melodies: "الألحان",
about: "من أنا",
contact: "تواصل",
},
legal: {
label: "الصفحات الرسمية",
privacy: "الخصوصية",
terms: "شروط الاستخدام",
impressum: "Impressum · بيانات المزوّد",
},
footerRights: "{year} جميع الحقوق محفوظة",
variants: {
comingSoon: {
@@ -92,6 +101,15 @@ const ar: Dictionary = {
channelsTitle: "روابطك المباشرة",
channelCta: "فتح الرابط",
channelFallback: "ستظهر القناة هنا بعد إضافة الرابط الفعلي",
formName: "الاسم",
formEmail: "البريد الإلكتروني",
formMessage: "الرسالة",
formSubmit: "إرسال الرسالة",
formSending: "جارٍ الإرسال…",
formSuccess: "تم استلام رسالتك، وسأعود إليك قريباً.",
formSavedWarning: "تم حفظ رسالتك، لكن تعذّر إرسال إشعار البريد حالياً.",
formInvalid: "تحقق من الاسم والبريد والرسالة وحاول مجدداً.",
formRateLimit: "تم تجاوز الحد المؤقت للإرسال. حاول بعد قليل.",
channels: [
{ key: "email", name: "البريد الإلكتروني", hint: "للتواصل المهني المباشر والاستفسارات" },
{ key: "linkedin", name: "لينكدإن", hint: "للعلاقات المهنية والعرض السريع للخبرات" },
+30 -12
View File
@@ -10,9 +10,18 @@ const en: Dictionary = {
themeDark: "Dark",
nav: {
home: "Home",
projects: "Projects",
apps: "Apps",
melodies: "Melodies",
about: "About",
contact: "Contact",
},
legal: {
label: "Legal pages",
privacy: "Privacy",
terms: "Terms",
impressum: "Legal notice",
},
footerRights: "{year} All rights reserved",
variants: {
comingSoon: {
@@ -28,16 +37,16 @@ const en: Dictionary = {
home: {
comingSoon: {
badge: "Coming Soon",
kicker: "Coming soon",
title: "A fresh version of this site is being prepared",
kicker: "Temporary notice",
title: "This website is temporarily closed while a new version is prepared",
description:
"The full website is currently being refined with a stronger visual identity, sharper content, and a cleaner launch-ready experience. It will be live soon.",
primaryCta: "Reach out when it launches",
"A cleaner and more complete version of the site is currently in progress. The full experience will return soon with updated content and a refined presentation.",
primaryCta: "Contact by email",
secondaryCta: "Stay tuned",
highlights: [
{ value: "New release", label: "Updated look and content" },
{ value: "Bilingual", label: "Arabic and English ready" },
{ value: "Soon", label: "Official launch" },
{ value: "Temporarily offline", label: "Public pages are paused for now" },
{ value: "New version", label: "Refined content and visual identity" },
{ value: "Soon", label: "Full launch will return shortly" },
],
},
full: {
@@ -92,6 +101,15 @@ const en: Dictionary = {
channelsTitle: "Direct contact links",
channelCta: "Open link",
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: [
{ key: "email", name: "Email", hint: "For direct professional contact and project inquiries" },
{ key: "linkedin", name: "LinkedIn", hint: "For professional background and networking" },
@@ -101,20 +119,20 @@ const en: Dictionary = {
metadata: {
home: {
comingSoon: {
title: "Coming Soon",
title: "Coming soon",
description: "A temporary landing page announcing the upcoming launch of the new personal website.",
},
full: {
title: "Home",
description: "A bilingual professional site presenting technical expertise, project readiness, and server-friendly deployment.",
description: "A bilingual professional website presenting technical experience, web development services, and deployment readiness.",
},
},
aboutTitle: "About",
aboutDescription: "Learn about experience, skills, and the working approach behind modern web builds.",
aboutDescription: "Learn more about experience, skills, and the working principles behind building modern web products.",
contactTitle: "Contact",
contactDescription: "Contact page wired for email and professional profile links through environment settings.",
contactDescription: "A contact page ready to surface email and professional profile links directly from configuration.",
notFoundTitle: "Page not found",
notFoundDescription: "The requested page could not be found. Return home or switch to the other language.",
notFoundDescription: "The page you requested could not be found. You can return to the home page or switch languages.",
},
};
+18
View File
@@ -17,9 +17,18 @@ export type CommonContent = {
themeDark: string;
nav: {
home: string;
projects: string;
apps: string;
melodies: string;
about: string;
contact: string;
};
legal: {
label: string;
privacy: string;
terms: string;
impressum: string;
};
footerRights: string;
variants: ModeVariants<CommonVariantContent>;
};
@@ -66,6 +75,15 @@ export type ContactContent = {
channelsTitle: string;
channelCta: string;
channelFallback: string;
formName: string;
formEmail: string;
formMessage: string;
formSubmit: string;
formSending: string;
formSuccess: string;
formSavedWarning: string;
formInvalid: string;
formRateLimit: string;
channels: ContactChannelContent[];
};
+51 -9
View File
@@ -1,27 +1,28 @@
name: ${COMPOSE_PROJECT_NAME:-diyaa}
services:
web:
build:
context: .
dockerfile: Dockerfile
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_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
NEXT_PUBLIC_GITHUB_URL: ${NEXT_PUBLIC_GITHUB_URL:-}
container_name: ${CONTAINER_NAME:-diyaa}
ports:
- "${APP_PORT:-30002}:3000"
env_file:
- .env
container_name: ${CONTAINER_NAME:-diyaa-website}
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: ${NODE_ENV:-production}
PORT: 3000
HOSTNAME: 0.0.0.0
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_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
@@ -33,3 +34,44 @@ services:
retries: 5
start_period: 20s
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,
});
}
+12 -3
View File
@@ -1,6 +1,7 @@
import ar from "@/content/ar";
import en from "@/content/en";
import type { Dictionary } from "@/content/types";
import { isSiteClosedMode } from "@/lib/site";
export const locales = ["ar", "en"] as const;
@@ -11,18 +12,26 @@ const dictionaries: Record<Locale, Dictionary> = {
en,
};
export function getActiveLocale(locale: Locale): Locale {
return isSiteClosedMode() ? "en" : locale;
}
export function getEnabledLocales(): Locale[] {
return isSiteClosedMode() ? ["en"] : [...locales];
}
export function isLocale(value: string): value is Locale {
return locales.includes(value as Locale);
}
export function getDictionary(locale: Locale): Dictionary {
return dictionaries[locale];
return dictionaries[getActiveLocale(locale)];
}
export function getDirection(locale: Locale): "rtl" | "ltr" {
return locale === "ar" ? "rtl" : "ltr";
return getActiveLocale(locale) === "ar" ? "rtl" : "ltr";
}
export function getLocaleName(locale: Locale): string {
return locale === "ar" ? "العربية" : "English";
return getActiveLocale(locale) === "ar" ? "العربية" : "English";
}

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