Compare commits
22
Commits
2b5e91377c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c26f41511f | ||
|
|
d87f3033c6 | ||
|
|
8c3849f023 | ||
|
|
2ef51e0cb9 | ||
|
|
e135a0243a | ||
|
|
30fe3e8cd0 | ||
|
|
44b5ab22ae | ||
|
|
349412ea03 | ||
|
|
743143af39 | ||
|
|
616e372705 | ||
|
|
cf5cde7a1c | ||
|
|
d0425d4683 | ||
|
|
03eca6c59c | ||
|
|
100b9e0e3e | ||
|
|
a43c47250b | ||
|
|
e51c786782 | ||
|
|
55013326a3 | ||
|
|
838e535650 | ||
|
|
a1749ba640 | ||
|
|
7136a4dc3e | ||
|
|
877e543cf8 | ||
|
|
0bb964f07e |
+45
-6
@@ -1,11 +1,50 @@
|
|||||||
|
# ─── Project ───────────────────────────────────────────────
|
||||||
COMPOSE_PROJECT_NAME=diyaa
|
COMPOSE_PROJECT_NAME=diyaa
|
||||||
CONTAINER_NAME=diyaa
|
CONTAINER_NAME=diyaa-website
|
||||||
APP_PORT=30002
|
|
||||||
|
# ─── App ───────────────────────────────────────────────────
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
NEXT_TELEMETRY_DISABLED=1
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# ─── Site ──────────────────────────────────────────────────
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://diyaa.de
|
||||||
|
# coming-soon | maintenance | full
|
||||||
NEXT_PUBLIC_SITE_MODE=coming-soon
|
NEXT_PUBLIC_SITE_MODE=coming-soon
|
||||||
|
|
||||||
NEXT_PUBLIC_SITE_URL=https://your-domain.example
|
# ─── Contact & Social ──────────────────────────────────────
|
||||||
NEXT_PUBLIC_CONTACT_EMAIL=
|
NEXT_PUBLIC_CONTACT_EMAIL=work@diyaa.de
|
||||||
NEXT_PUBLIC_LINKEDIN_URL=
|
NEXT_PUBLIC_LINKEDIN_URL=https://linkedin.com/in/YOUR_USERNAME
|
||||||
NEXT_PUBLIC_GITHUB_URL=
|
NEXT_PUBLIC_GITHUB_URL=https://github.com/YOUR_USERNAME
|
||||||
|
|
||||||
|
# ─── Database (PostgreSQL + Prisma) ───────────────────────
|
||||||
|
# Development-only defaults. Keep real credentials in .env, never in git.
|
||||||
|
POSTGRES_DB=diyaa
|
||||||
|
POSTGRES_USER=diyaa
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
POSTGRES_PORT=55432
|
||||||
|
DATABASE_URL=postgresql://diyaa:change-me@localhost:55432/diyaa?schema=public
|
||||||
|
UPLOAD_DIR=/app/uploads
|
||||||
|
|
||||||
|
# ─── Authentication ───────────────────────────────────────
|
||||||
|
AUTH_SECRET=replace-with-a-long-random-secret
|
||||||
|
AUTH_URL=http://localhost:3000
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=replace-with-a-long-admin-password
|
||||||
|
|
||||||
|
# ─── SMTP Email ────────────────────────────────────────────
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=replace-with-smtp-user
|
||||||
|
SMTP_PASSWORD=replace-with-smtp-password
|
||||||
|
SMTP_FROM=website@example.com
|
||||||
|
SMTP_TO=work@diyaa.de
|
||||||
|
|
||||||
|
# ─── Legal identity ───────────────────────────────────────
|
||||||
|
LEGAL_NAME=Diyaa
|
||||||
|
LEGAL_ADDRESS=Street and house number
|
||||||
|
LEGAL_POSTAL_CODE=00000
|
||||||
|
LEGAL_CITY=City
|
||||||
|
LEGAL_COUNTRY=Germany
|
||||||
|
# Optional when applicable.
|
||||||
|
LEGAL_VAT_ID=
|
||||||
|
|||||||
@@ -3,5 +3,7 @@
|
|||||||
.env.local
|
.env.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
.next
|
.next
|
||||||
|
uploads
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
@@ -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) شغّالة.
|
||||||
@@ -38,6 +38,7 @@ ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
|
|||||||
ENV NEXT_PUBLIC_LINKEDIN_URL=$NEXT_PUBLIC_LINKEDIN_URL
|
ENV NEXT_PUBLIC_LINKEDIN_URL=$NEXT_PUBLIC_LINKEDIN_URL
|
||||||
ENV NEXT_PUBLIC_GITHUB_URL=$NEXT_PUBLIC_GITHUB_URL
|
ENV NEXT_PUBLIC_GITHUB_URL=$NEXT_PUBLIC_GITHUB_URL
|
||||||
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
|
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
|
||||||
|
RUN mkdir -p /app/uploads && chown nextjs:nextjs /app/uploads
|
||||||
|
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
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 { buildPageMetadata } from "@/lib/metadata";
|
||||||
import { isComingSoonMode } from "@/lib/site";
|
import { isSiteClosedMode } from "@/lib/site";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
|
||||||
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
|
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
|
||||||
@@ -9,7 +9,7 @@ export function generateMetadata({ params }: { params: { locale: string } }): Me
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildPageMetadata(params.locale, "about");
|
return buildPageMetadata(getActiveLocale(params.locale), "about");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AboutPage({ params }: { params: { locale: string } }) {
|
export default function AboutPage({ params }: { params: { locale: string } }) {
|
||||||
@@ -17,11 +17,11 @@ export default function AboutPage({ params }: { params: { locale: string } }) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isComingSoonMode()) {
|
if (isSiteClosedMode()) {
|
||||||
redirect(`/${params.locale}`);
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const locale = params.locale as Locale;
|
const locale = getActiveLocale(params.locale as Locale);
|
||||||
const dictionary = getDictionary(locale);
|
const dictionary = getDictionary(locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { Metadata } from "next";
|
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 { buildPageMetadata } from "@/lib/metadata";
|
||||||
import { getContactChannels, isComingSoonMode } from "@/lib/site";
|
import { getContactChannels, isSiteClosedMode } from "@/lib/site";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import ContactForm from "@/components/public/contact-form";
|
||||||
|
|
||||||
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
|
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
|
||||||
if (!isLocale(params.locale)) {
|
if (!isLocale(params.locale)) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildPageMetadata(params.locale, "contact");
|
return buildPageMetadata(getActiveLocale(params.locale), "contact");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContactPage({ params }: { params: { locale: string } }) {
|
export default function ContactPage({ params }: { params: { locale: string } }) {
|
||||||
@@ -17,11 +18,11 @@ export default function ContactPage({ params }: { params: { locale: string } })
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isComingSoonMode()) {
|
if (isSiteClosedMode()) {
|
||||||
redirect(`/${params.locale}`);
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
const locale = params.locale as Locale;
|
const locale = getActiveLocale(params.locale as Locale);
|
||||||
const dictionary = getDictionary(locale);
|
const dictionary = getDictionary(locale);
|
||||||
const channels = getContactChannels(dictionary.contact);
|
const channels = getContactChannels(dictionary.contact);
|
||||||
|
|
||||||
@@ -38,6 +39,20 @@ export default function ContactPage({ params }: { params: { locale: string } })
|
|||||||
<p>{dictionary.contact.availabilityDescription}</p>
|
<p>{dictionary.contact.availabilityDescription}</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<ContactForm
|
||||||
|
labels={{
|
||||||
|
name: dictionary.contact.formName,
|
||||||
|
email: dictionary.contact.formEmail,
|
||||||
|
message: dictionary.contact.formMessage,
|
||||||
|
submit: dictionary.contact.formSubmit,
|
||||||
|
sending: dictionary.contact.formSending,
|
||||||
|
success: dictionary.contact.formSuccess,
|
||||||
|
savedWarning: dictionary.contact.formSavedWarning,
|
||||||
|
invalid: dictionary.contact.formInvalid,
|
||||||
|
rateLimit: dictionary.contact.formRateLimit,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<h2>{dictionary.contact.channelsTitle}</h2>
|
<h2>{dictionary.contact.channelsTitle}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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
@@ -1,14 +1,16 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import BottomNav from "@/components/BottomNav";
|
import BottomNav from "@/components/BottomNav";
|
||||||
import SiteFooter from "@/components/SiteFooter";
|
import SiteFooter from "@/components/SiteFooter";
|
||||||
import SiteHeader from "@/components/SiteHeader";
|
import SiteHeader from "@/components/SiteHeader";
|
||||||
import { 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 const dynamicParams = false;
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return locales.map((locale) => ({ locale }));
|
return getEnabledLocales().map((locale) => ({ locale }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LocaleLayout({
|
export default function LocaleLayout({
|
||||||
@@ -22,12 +24,17 @@ export default function LocaleLayout({
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const locale = params.locale as Locale;
|
if (isSiteClosedMode()) {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = getActiveLocale(params.locale as Locale);
|
||||||
const dictionary = getDictionary(locale);
|
const dictionary = getDictionary(locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div dir={getDirection(locale)} className="site-shell">
|
<div dir={getDirection(locale)} className="site-shell">
|
||||||
<SiteHeader locale={locale} common={dictionary.common} />
|
<SiteHeader locale={locale} common={dictionary.common} />
|
||||||
|
<AnalyticsTracker event="PAGE_VIEW" />
|
||||||
<main className="page-content">{children}</main>
|
<main className="page-content">{children}</main>
|
||||||
<BottomNav locale={locale} common={dictionary.common} />
|
<BottomNav locale={locale} common={dictionary.common} />
|
||||||
<SiteFooter locale={locale} common={dictionary.common} />
|
<SiteFooter locale={locale} common={dictionary.common} />
|
||||||
|
|||||||
@@ -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")} />;
|
||||||
|
}
|
||||||
@@ -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")} />;
|
||||||
|
}
|
||||||
@@ -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")} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import HeroSection from "@/components/HeroSection";
|
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 { buildPageMetadata } from "@/lib/metadata";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ export function generateMetadata({ params }: { params: { locale: string } }): Me
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildPageMetadata(params.locale, "home");
|
return buildPageMetadata(getActiveLocale(params.locale), "home");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage({ params }: { params: { locale: string } }) {
|
export default function HomePage({ params }: { params: { locale: string } }) {
|
||||||
@@ -17,7 +17,7 @@ export default function HomePage({ params }: { params: { locale: string } }) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const locale = params.locale as Locale;
|
const locale = getActiveLocale(params.locale as Locale);
|
||||||
const dictionary = getDictionary(locale);
|
const dictionary = getDictionary(locale);
|
||||||
|
|
||||||
return <HeroSection locale={locale} home={dictionary.home} />;
|
return <HeroSection locale={locale} home={dictionary.home} />;
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
@@ -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
@@ -1,3 +1,7 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #07111f;
|
--bg: #07111f;
|
||||||
--surface: rgba(11, 20, 36, 0.84);
|
--surface: rgba(11, 20, 36, 0.84);
|
||||||
@@ -200,6 +204,22 @@ a {
|
|||||||
animation: fade-up 460ms ease both;
|
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 {
|
.panel {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
@@ -305,6 +325,72 @@ a {
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
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 {
|
.split-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -531,6 +617,24 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.split-grid,
|
.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 {
|
.stat-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,31 +1,45 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { siteConfig } from "@/lib/site";
|
import { isSiteClosedMode, siteConfig } from "@/lib/site";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export function generateMetadata(): Metadata {
|
||||||
|
const siteClosed = isSiteClosedMode();
|
||||||
|
|
||||||
|
return {
|
||||||
metadataBase: new URL(siteConfig.siteUrl),
|
metadataBase: new URL(siteConfig.siteUrl),
|
||||||
title: {
|
title: {
|
||||||
default: "Diyaa",
|
default: "Diyaa",
|
||||||
template: "%s | 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",
|
applicationName: "Diyaa",
|
||||||
authors: [{ name: "Diyaa" }],
|
authors: [{ name: "Diyaa" }],
|
||||||
creator: "Diyaa",
|
creator: "Diyaa",
|
||||||
publisher: "Diyaa",
|
publisher: "Diyaa",
|
||||||
alternates: {
|
alternates: {
|
||||||
languages: {
|
languages: siteClosed
|
||||||
|
? {
|
||||||
|
en: "/",
|
||||||
|
"x-default": "/",
|
||||||
|
}
|
||||||
|
: {
|
||||||
ar: "/ar",
|
ar: "/ar",
|
||||||
en: "/en",
|
en: "/en",
|
||||||
"x-default": "/ar",
|
"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";
|
const direction = locale === "ar" ? "rtl" : "ltr";
|
||||||
document.documentElement.lang = locale;
|
document.documentElement.lang = locale;
|
||||||
document.documentElement.dir = direction;
|
document.documentElement.dir = direction;
|
||||||
@@ -38,12 +52,15 @@ const themeScript = `
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
const siteClosed = isSiteClosedMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="ar" dir="rtl" data-theme="dark" suppressHydrationWarning>
|
<html lang={siteClosed ? "en" : "ar"} dir={siteClosed ? "ltr" : "rtl"} data-theme="dark" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
|
<script dangerouslySetInnerHTML={{ __html: getThemeScript() }} />
|
||||||
</head>
|
</head>
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+14
-1
@@ -1,6 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { isSiteClosedMode } from "@/lib/site";
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
|
const siteClosed = isSiteClosedMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="page-content">
|
<main className="page-content">
|
||||||
<section className="panel section-stack">
|
<section className="panel section-stack">
|
||||||
@@ -8,17 +11,27 @@ export default function NotFound() {
|
|||||||
<p className="eyebrow">404</p>
|
<p className="eyebrow">404</p>
|
||||||
<h1>الصفحة غير موجودة</h1>
|
<h1>الصفحة غير موجودة</h1>
|
||||||
<p className="lead">
|
<p className="lead">
|
||||||
الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى النسخة العربية أو الإنجليزية من الصفحة الرئيسية.
|
{siteClosed
|
||||||
|
? "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى الصفحة الرئيسية."
|
||||||
|
: "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى النسخة العربية أو الإنجليزية من الصفحة الرئيسية."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="cta-row">
|
<div className="cta-row">
|
||||||
|
{siteClosed ? (
|
||||||
|
<Link href="/" className="cta-btn">
|
||||||
|
العودة إلى الرئيسية
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Link href="/ar" className="cta-btn">
|
<Link href="/ar" className="cta-btn">
|
||||||
العودة إلى العربية
|
العودة إلى العربية
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/en" className="ghost-btn">
|
<Link href="/en" className="ghost-btn">
|
||||||
Go to English
|
Go to English
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
@@ -1,5 +1,14 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { isSiteClosedMode } from "@/lib/site";
|
||||||
|
|
||||||
export default function RootPage() {
|
export default function RootPage() {
|
||||||
|
if (isSiteClosedMode()) {
|
||||||
|
return (
|
||||||
|
<main className="coming-soon-page">
|
||||||
|
<h1 className="coming-soon-title">Coming Soon123</h1>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
redirect("/ar");
|
redirect("/ar");
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-3
@@ -1,11 +1,15 @@
|
|||||||
import type { MetadataRoute } from "next";
|
import type { MetadataRoute } from "next";
|
||||||
import { getLocalizedPath, siteConfig } from "@/lib/site";
|
import { getLocalizedPath, isSiteClosedMode, siteConfig } from "@/lib/site";
|
||||||
|
import type { Locale } from "@/lib/i18n";
|
||||||
|
|
||||||
const pages = ["", "/about", "/contact"] as const;
|
const allPages = ["", "/about", "/contact", "/legal/privacy", "/legal/terms", "/legal/impressum"] as const;
|
||||||
const locales = ["ar", "en"] as const;
|
const allLocales = ["ar", "en"] as const;
|
||||||
|
|
||||||
export default function sitemap(): MetadataRoute.Sitemap {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const lastModified = new Date();
|
const lastModified = new Date();
|
||||||
|
const siteClosed = isSiteClosedMode();
|
||||||
|
const pages: readonly (typeof allPages)[number][] = siteClosed ? [] : allPages;
|
||||||
|
const locales: readonly Locale[] = siteClosed ? [] : allLocales;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import ComingSoonPanel from "@/components/ComingSoonPanel";
|
||||||
import type { HomeContent, HomeVariantContent } from "@/content/types";
|
import type { HomeContent, HomeVariantContent } from "@/content/types";
|
||||||
import type { Locale } from "@/lib/i18n";
|
import type { Locale } from "@/lib/i18n";
|
||||||
import { getEmailHref, getModeValue, isComingSoonMode } from "@/lib/site";
|
import { getEmailHref, getModeValue, isComingSoonMode } from "@/lib/site";
|
||||||
@@ -10,8 +11,13 @@ type HeroSectionProps = {
|
|||||||
|
|
||||||
export default function HeroSection({ locale, home }: HeroSectionProps) {
|
export default function HeroSection({ locale, home }: HeroSectionProps) {
|
||||||
const activeHome: HomeVariantContent = getModeValue(home);
|
const activeHome: HomeVariantContent = getModeValue(home);
|
||||||
|
const comingSoon = isComingSoonMode();
|
||||||
const emailHref = getEmailHref();
|
const emailHref = getEmailHref();
|
||||||
|
|
||||||
|
if (comingSoon) {
|
||||||
|
return <ComingSoonPanel content={activeHome} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="hero panel">
|
<section className="hero panel">
|
||||||
<p className="availability-badge">{activeHome.badge}</p>
|
<p className="availability-badge">{activeHome.badge}</p>
|
||||||
@@ -29,7 +35,7 @@ export default function HeroSection({ locale, home }: HeroSectionProps) {
|
|||||||
{activeHome.primaryCta}
|
{activeHome.primaryCta}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Link href={isComingSoonMode() ? `/${locale}` : `/${locale}/about`} className="ghost-btn">
|
<Link href={`/${locale}/about`} className="ghost-btn">
|
||||||
{activeHome.secondaryCta}
|
{activeHome.secondaryCta}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import type { Locale } from "@/lib/i18n";
|
import type { Locale } from "@/lib/i18n";
|
||||||
import type { CommonContent } from "@/content/types";
|
import type { CommonContent } from "@/content/types";
|
||||||
import { getModeValue } from "@/lib/site";
|
import { getModeValue, isComingSoonMode } from "@/lib/site";
|
||||||
|
|
||||||
type SiteFooterProps = {
|
type SiteFooterProps = {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
@@ -10,6 +11,7 @@ type SiteFooterProps = {
|
|||||||
export default function SiteFooter({ locale, common }: SiteFooterProps) {
|
export default function SiteFooter({ locale, common }: SiteFooterProps) {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const commonVariant = getModeValue(common.variants);
|
const commonVariant = getModeValue(common.variants);
|
||||||
|
const isComingSoon = isComingSoonMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="site-footer">
|
<footer className="site-footer">
|
||||||
@@ -18,8 +20,17 @@ export default function SiteFooter({ locale, common }: SiteFooterProps) {
|
|||||||
<p>{common.footerRights.replace("{year}", String(year))}</p>
|
<p>{common.footerRights.replace("{year}", String(year))}</p>
|
||||||
<p>{commonVariant.footerBuiltWith}</p>
|
<p>{commonVariant.footerBuiltWith}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{!isComingSoon ? (
|
||||||
|
<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>
|
<p className="locale-badge">{locale.toUpperCase()}</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,16 @@ export default function SiteHeader({ locale, common }: SiteHeaderProps) {
|
|||||||
{!isComingSoon ? (
|
{!isComingSoon ? (
|
||||||
<nav className="header-nav" aria-label={common.navLabel}>
|
<nav className="header-nav" aria-label={common.navLabel}>
|
||||||
<Link href={`/${locale}`}>{common.nav.home}</Link>
|
<Link href={`/${locale}`}>{common.nav.home}</Link>
|
||||||
|
<Link href={`/${locale}/work`}>{common.nav.projects}</Link>
|
||||||
|
<Link href={`/${locale}/apps`}>{common.nav.apps}</Link>
|
||||||
|
<Link href={`/${locale}/melodies`}>{common.nav.melodies}</Link>
|
||||||
<Link href={`/${locale}/about`}>{common.nav.about}</Link>
|
<Link href={`/${locale}/about`}>{common.nav.about}</Link>
|
||||||
<Link href={`/${locale}/contact`}>{common.nav.contact}</Link>
|
<Link href={`/${locale}/contact`}>{common.nav.contact}</Link>
|
||||||
</nav>
|
</nav>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="header-actions">
|
<div className="header-actions">
|
||||||
<LanguageSwitcher locale={locale} label={common.languageSwitcherLabel} />
|
{!isComingSoon ? <LanguageSwitcher locale={locale} label={common.languageSwitcherLabel} /> : null}
|
||||||
<ThemeToggle
|
<ThemeToggle
|
||||||
label={common.themeToggleLabel}
|
label={common.themeToggleLabel}
|
||||||
lightLabel={common.themeLight}
|
lightLabel={common.themeLight}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -10,9 +10,18 @@ const ar: Dictionary = {
|
|||||||
themeDark: "داكن",
|
themeDark: "داكن",
|
||||||
nav: {
|
nav: {
|
||||||
home: "الرئيسية",
|
home: "الرئيسية",
|
||||||
|
projects: "المشاريع",
|
||||||
|
apps: "التطبيقات",
|
||||||
|
melodies: "الألحان",
|
||||||
about: "من أنا",
|
about: "من أنا",
|
||||||
contact: "تواصل",
|
contact: "تواصل",
|
||||||
},
|
},
|
||||||
|
legal: {
|
||||||
|
label: "الصفحات الرسمية",
|
||||||
|
privacy: "الخصوصية",
|
||||||
|
terms: "شروط الاستخدام",
|
||||||
|
impressum: "Impressum · بيانات المزوّد",
|
||||||
|
},
|
||||||
footerRights: "{year} جميع الحقوق محفوظة",
|
footerRights: "{year} جميع الحقوق محفوظة",
|
||||||
variants: {
|
variants: {
|
||||||
comingSoon: {
|
comingSoon: {
|
||||||
@@ -92,6 +101,15 @@ const ar: Dictionary = {
|
|||||||
channelsTitle: "روابطك المباشرة",
|
channelsTitle: "روابطك المباشرة",
|
||||||
channelCta: "فتح الرابط",
|
channelCta: "فتح الرابط",
|
||||||
channelFallback: "ستظهر القناة هنا بعد إضافة الرابط الفعلي",
|
channelFallback: "ستظهر القناة هنا بعد إضافة الرابط الفعلي",
|
||||||
|
formName: "الاسم",
|
||||||
|
formEmail: "البريد الإلكتروني",
|
||||||
|
formMessage: "الرسالة",
|
||||||
|
formSubmit: "إرسال الرسالة",
|
||||||
|
formSending: "جارٍ الإرسال…",
|
||||||
|
formSuccess: "تم استلام رسالتك، وسأعود إليك قريباً.",
|
||||||
|
formSavedWarning: "تم حفظ رسالتك، لكن تعذّر إرسال إشعار البريد حالياً.",
|
||||||
|
formInvalid: "تحقق من الاسم والبريد والرسالة وحاول مجدداً.",
|
||||||
|
formRateLimit: "تم تجاوز الحد المؤقت للإرسال. حاول بعد قليل.",
|
||||||
channels: [
|
channels: [
|
||||||
{ key: "email", name: "البريد الإلكتروني", hint: "للتواصل المهني المباشر والاستفسارات" },
|
{ key: "email", name: "البريد الإلكتروني", hint: "للتواصل المهني المباشر والاستفسارات" },
|
||||||
{ key: "linkedin", name: "لينكدإن", hint: "للعلاقات المهنية والعرض السريع للخبرات" },
|
{ key: "linkedin", name: "لينكدإن", hint: "للعلاقات المهنية والعرض السريع للخبرات" },
|
||||||
|
|||||||
+30
-12
@@ -10,9 +10,18 @@ const en: Dictionary = {
|
|||||||
themeDark: "Dark",
|
themeDark: "Dark",
|
||||||
nav: {
|
nav: {
|
||||||
home: "Home",
|
home: "Home",
|
||||||
|
projects: "Projects",
|
||||||
|
apps: "Apps",
|
||||||
|
melodies: "Melodies",
|
||||||
about: "About",
|
about: "About",
|
||||||
contact: "Contact",
|
contact: "Contact",
|
||||||
},
|
},
|
||||||
|
legal: {
|
||||||
|
label: "Legal pages",
|
||||||
|
privacy: "Privacy",
|
||||||
|
terms: "Terms",
|
||||||
|
impressum: "Legal notice",
|
||||||
|
},
|
||||||
footerRights: "{year} All rights reserved",
|
footerRights: "{year} All rights reserved",
|
||||||
variants: {
|
variants: {
|
||||||
comingSoon: {
|
comingSoon: {
|
||||||
@@ -28,16 +37,16 @@ const en: Dictionary = {
|
|||||||
home: {
|
home: {
|
||||||
comingSoon: {
|
comingSoon: {
|
||||||
badge: "Coming Soon",
|
badge: "Coming Soon",
|
||||||
kicker: "Coming soon",
|
kicker: "Temporary notice",
|
||||||
title: "A fresh version of this site is being prepared",
|
title: "This website is temporarily closed while a new version is prepared",
|
||||||
description:
|
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.",
|
"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: "Reach out when it launches",
|
primaryCta: "Contact by email",
|
||||||
secondaryCta: "Stay tuned",
|
secondaryCta: "Stay tuned",
|
||||||
highlights: [
|
highlights: [
|
||||||
{ value: "New release", label: "Updated look and content" },
|
{ value: "Temporarily offline", label: "Public pages are paused for now" },
|
||||||
{ value: "Bilingual", label: "Arabic and English ready" },
|
{ value: "New version", label: "Refined content and visual identity" },
|
||||||
{ value: "Soon", label: "Official launch" },
|
{ value: "Soon", label: "Full launch will return shortly" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
full: {
|
full: {
|
||||||
@@ -92,6 +101,15 @@ const en: Dictionary = {
|
|||||||
channelsTitle: "Direct contact links",
|
channelsTitle: "Direct contact links",
|
||||||
channelCta: "Open link",
|
channelCta: "Open link",
|
||||||
channelFallback: "This channel will appear once the real link is added",
|
channelFallback: "This channel will appear once the real link is added",
|
||||||
|
formName: "Name",
|
||||||
|
formEmail: "Email",
|
||||||
|
formMessage: "Message",
|
||||||
|
formSubmit: "Send message",
|
||||||
|
formSending: "Sending…",
|
||||||
|
formSuccess: "Your message was received. I’ll get back to you soon.",
|
||||||
|
formSavedWarning: "Your message was saved, but the email notification could not be sent right now.",
|
||||||
|
formInvalid: "Check your name, email, and message, then try again.",
|
||||||
|
formRateLimit: "The temporary sending limit was reached. Please try again later.",
|
||||||
channels: [
|
channels: [
|
||||||
{ key: "email", name: "Email", hint: "For direct professional contact and project inquiries" },
|
{ key: "email", name: "Email", hint: "For direct professional contact and project inquiries" },
|
||||||
{ key: "linkedin", name: "LinkedIn", hint: "For professional background and networking" },
|
{ key: "linkedin", name: "LinkedIn", hint: "For professional background and networking" },
|
||||||
@@ -101,20 +119,20 @@ const en: Dictionary = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
home: {
|
home: {
|
||||||
comingSoon: {
|
comingSoon: {
|
||||||
title: "Coming Soon",
|
title: "Coming soon",
|
||||||
description: "A temporary landing page announcing the upcoming launch of the new personal website.",
|
description: "A temporary landing page announcing the upcoming launch of the new personal website.",
|
||||||
},
|
},
|
||||||
full: {
|
full: {
|
||||||
title: "Home",
|
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",
|
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",
|
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",
|
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.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,18 @@ export type CommonContent = {
|
|||||||
themeDark: string;
|
themeDark: string;
|
||||||
nav: {
|
nav: {
|
||||||
home: string;
|
home: string;
|
||||||
|
projects: string;
|
||||||
|
apps: string;
|
||||||
|
melodies: string;
|
||||||
about: string;
|
about: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
};
|
};
|
||||||
|
legal: {
|
||||||
|
label: string;
|
||||||
|
privacy: string;
|
||||||
|
terms: string;
|
||||||
|
impressum: string;
|
||||||
|
};
|
||||||
footerRights: string;
|
footerRights: string;
|
||||||
variants: ModeVariants<CommonVariantContent>;
|
variants: ModeVariants<CommonVariantContent>;
|
||||||
};
|
};
|
||||||
@@ -66,6 +75,15 @@ export type ContactContent = {
|
|||||||
channelsTitle: string;
|
channelsTitle: string;
|
||||||
channelCta: string;
|
channelCta: string;
|
||||||
channelFallback: string;
|
channelFallback: string;
|
||||||
|
formName: string;
|
||||||
|
formEmail: string;
|
||||||
|
formMessage: string;
|
||||||
|
formSubmit: string;
|
||||||
|
formSending: string;
|
||||||
|
formSuccess: string;
|
||||||
|
formSavedWarning: string;
|
||||||
|
formInvalid: string;
|
||||||
|
formRateLimit: string;
|
||||||
channels: ContactChannelContent[];
|
channels: ContactChannelContent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+51
-9
@@ -1,27 +1,28 @@
|
|||||||
name: ${COMPOSE_PROJECT_NAME:-diyaa}
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://example.com}
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://diyaa.de}
|
||||||
NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon}
|
NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon}
|
||||||
NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
|
NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
|
||||||
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
|
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
|
||||||
NEXT_PUBLIC_GITHUB_URL: ${NEXT_PUBLIC_GITHUB_URL:-}
|
NEXT_PUBLIC_GITHUB_URL: ${NEXT_PUBLIC_GITHUB_URL:-}
|
||||||
container_name: ${CONTAINER_NAME:-diyaa}
|
container_name: ${CONTAINER_NAME:-diyaa-website}
|
||||||
ports:
|
depends_on:
|
||||||
- "${APP_PORT:-30002}:3000"
|
postgres:
|
||||||
env_file:
|
condition: service_healthy
|
||||||
- .env
|
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: ${NODE_ENV:-production}
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-1}
|
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-1}
|
||||||
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://example.com}
|
DATABASE_URL: postgresql://${POSTGRES_USER:-diyaa}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-diyaa}?schema=public
|
||||||
|
UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads}
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET:-}
|
||||||
|
AUTH_URL: ${AUTH_URL:-https://diyaa.de}
|
||||||
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://diyaa.de}
|
||||||
NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon}
|
NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon}
|
||||||
NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
|
NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
|
||||||
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
|
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
|
||||||
@@ -33,3 +34,44 @@ services:
|
|||||||
retries: 5
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- uploads_data:/app/uploads
|
||||||
|
networks:
|
||||||
|
- traefik-public
|
||||||
|
- app-internal
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.diyaa-website.rule=Host(`diyaa.de`)"
|
||||||
|
- "traefik.http.routers.diyaa-website.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.diyaa-website.tls.certresolver=le"
|
||||||
|
- "traefik.http.services.diyaa-website.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: ${POSTGRES_CONTAINER_NAME:-diyaa-postgres}
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-diyaa}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-diyaa}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${POSTGRES_PORT:-55432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-diyaa} -d ${POSTGRES_DB:-diyaa}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
networks:
|
||||||
|
- app-internal
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik-public:
|
||||||
|
external: true
|
||||||
|
app-internal:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
uploads_data:
|
||||||
|
|||||||
@@ -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**.
|
||||||
@@ -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
|
||||||
@@ -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-##`، واحذفها من هون أو علّمها ✅ منقولة.
|
||||||
@@ -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. خلّي هالملف يعكس **الواقع** لا الطموح.
|
||||||
@@ -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`.
|
||||||
|
|
||||||
|
> القاعدة الذهبية: **لا تخلّي الكود يسبق التوثيق.** إذا الكود تغيّر والتوثيق لأ — هاد باگ.
|
||||||
@@ -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.
|
||||||
@@ -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` بعد كل إضافة.
|
||||||
@@ -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`.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
import ar from "@/content/ar";
|
import ar from "@/content/ar";
|
||||||
import en from "@/content/en";
|
import en from "@/content/en";
|
||||||
import type { Dictionary } from "@/content/types";
|
import type { Dictionary } from "@/content/types";
|
||||||
|
import { isSiteClosedMode } from "@/lib/site";
|
||||||
|
|
||||||
export const locales = ["ar", "en"] as const;
|
export const locales = ["ar", "en"] as const;
|
||||||
|
|
||||||
@@ -11,18 +12,26 @@ const dictionaries: Record<Locale, Dictionary> = {
|
|||||||
en,
|
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 {
|
export function isLocale(value: string): value is Locale {
|
||||||
return locales.includes(value as Locale);
|
return locales.includes(value as Locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDictionary(locale: Locale): Dictionary {
|
export function getDictionary(locale: Locale): Dictionary {
|
||||||
return dictionaries[locale];
|
return dictionaries[getActiveLocale(locale)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDirection(locale: Locale): "rtl" | "ltr" {
|
export function getDirection(locale: Locale): "rtl" | "ltr" {
|
||||||
return locale === "ar" ? "rtl" : "ltr";
|
return getActiveLocale(locale) === "ar" ? "rtl" : "ltr";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLocaleName(locale: Locale): string {
|
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
Reference in New Issue
Block a user