Compare commits
26
Commits
243a182d33
...
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 | ||
|
|
2b5e91377c | ||
|
|
c9e9ae6b90 | ||
|
|
d1ed5c641b | ||
|
|
cdf83cd466 |
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
npm-debug.log*
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# ─── Project ───────────────────────────────────────────────
|
||||||
|
COMPOSE_PROJECT_NAME=diyaa
|
||||||
|
CONTAINER_NAME=diyaa-website
|
||||||
|
|
||||||
|
# ─── App ───────────────────────────────────────────────────
|
||||||
|
NODE_ENV=production
|
||||||
|
NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# ─── Site ──────────────────────────────────────────────────
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://diyaa.de
|
||||||
|
# coming-soon | maintenance | full
|
||||||
|
NEXT_PUBLIC_SITE_MODE=coming-soon
|
||||||
|
|
||||||
|
# ─── Contact & Social ──────────────────────────────────────
|
||||||
|
NEXT_PUBLIC_CONTACT_EMAIL=work@diyaa.de
|
||||||
|
NEXT_PUBLIC_LINKEDIN_URL=https://linkedin.com/in/YOUR_USERNAME
|
||||||
|
NEXT_PUBLIC_GITHUB_URL=https://github.com/YOUR_USERNAME
|
||||||
|
|
||||||
|
# ─── Database (PostgreSQL + Prisma) ───────────────────────
|
||||||
|
# Development-only defaults. Keep real credentials in .env, never in git.
|
||||||
|
POSTGRES_DB=diyaa
|
||||||
|
POSTGRES_USER=diyaa
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
POSTGRES_PORT=55432
|
||||||
|
DATABASE_URL=postgresql://diyaa:change-me@localhost:55432/diyaa?schema=public
|
||||||
|
UPLOAD_DIR=/app/uploads
|
||||||
|
|
||||||
|
# ─── Authentication ───────────────────────────────────────
|
||||||
|
AUTH_SECRET=replace-with-a-long-random-secret
|
||||||
|
AUTH_URL=http://localhost:3000
|
||||||
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_PASSWORD=replace-with-a-long-admin-password
|
||||||
|
|
||||||
|
# ─── SMTP Email ────────────────────────────────────────────
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=replace-with-smtp-user
|
||||||
|
SMTP_PASSWORD=replace-with-smtp-password
|
||||||
|
SMTP_FROM=website@example.com
|
||||||
|
SMTP_TO=work@diyaa.de
|
||||||
|
|
||||||
|
# ─── Legal identity ───────────────────────────────────────
|
||||||
|
LEGAL_NAME=Diyaa
|
||||||
|
LEGAL_ADDRESS=Street and house number
|
||||||
|
LEGAL_POSTAL_CODE=00000
|
||||||
|
LEGAL_CITY=City
|
||||||
|
LEGAL_COUNTRY=Germany
|
||||||
|
# Optional when applicable.
|
||||||
|
LEGAL_VAT_ID=
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production.local
|
||||||
|
.next
|
||||||
|
uploads
|
||||||
|
node_modules
|
||||||
|
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) شغّالة.
|
||||||
+24
@@ -1,10 +1,22 @@
|
|||||||
FROM node:20-alpine AS deps
|
FROM node:20-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||||
|
|
||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
ARG NEXT_PUBLIC_SITE_URL=https://example.com
|
||||||
|
ARG NEXT_PUBLIC_SITE_MODE=coming-soon
|
||||||
|
ARG NEXT_PUBLIC_CONTACT_EMAIL=
|
||||||
|
ARG NEXT_PUBLIC_LINKEDIN_URL=
|
||||||
|
ARG NEXT_PUBLIC_GITHUB_URL=
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||||
|
ENV NEXT_PUBLIC_SITE_MODE=$NEXT_PUBLIC_SITE_MODE
|
||||||
|
ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
|
||||||
|
ENV NEXT_PUBLIC_LINKEDIN_URL=$NEXT_PUBLIC_LINKEDIN_URL
|
||||||
|
ENV NEXT_PUBLIC_GITHUB_URL=$NEXT_PUBLIC_GITHUB_URL
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -14,7 +26,19 @@ WORKDIR /app
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME=0.0.0.0
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ARG NEXT_PUBLIC_SITE_URL=https://example.com
|
||||||
|
ARG NEXT_PUBLIC_SITE_MODE=coming-soon
|
||||||
|
ARG NEXT_PUBLIC_CONTACT_EMAIL=
|
||||||
|
ARG NEXT_PUBLIC_LINKEDIN_URL=
|
||||||
|
ARG NEXT_PUBLIC_GITHUB_URL=
|
||||||
|
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
|
||||||
|
ENV NEXT_PUBLIC_SITE_MODE=$NEXT_PUBLIC_SITE_MODE
|
||||||
|
ENV NEXT_PUBLIC_CONTACT_EMAIL=$NEXT_PUBLIC_CONTACT_EMAIL
|
||||||
|
ENV NEXT_PUBLIC_LINKEDIN_URL=$NEXT_PUBLIC_LINKEDIN_URL
|
||||||
|
ENV NEXT_PUBLIC_GITHUB_URL=$NEXT_PUBLIC_GITHUB_URL
|
||||||
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
|
RUN 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 ./
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Diyaa Website
|
||||||
|
|
||||||
|
A bilingual Next.js website prepared for deployment on a private Linux VPS with Docker.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Next.js 14
|
||||||
|
- React 18
|
||||||
|
- TypeScript
|
||||||
|
- Docker / Docker Compose
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env`
|
||||||
|
2. Fill in `NEXT_PUBLIC_SITE_URL` and your real contact links
|
||||||
|
3. Choose `NEXT_PUBLIC_SITE_MODE=coming-soon` or `NEXT_PUBLIC_SITE_MODE=full`
|
||||||
|
4. Install dependencies with `npm install`
|
||||||
|
5. Run `npm run dev`
|
||||||
|
|
||||||
|
## Quality checks
|
||||||
|
|
||||||
|
- `npm run lint`
|
||||||
|
- `npm run typecheck`
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run check`
|
||||||
|
|
||||||
|
## Docker workflow
|
||||||
|
|
||||||
|
- `make build`
|
||||||
|
- `make start`
|
||||||
|
- `make ps`
|
||||||
|
- `make logs`
|
||||||
|
- `make stop`
|
||||||
|
|
||||||
|
The container healthcheck uses `/api/health`.
|
||||||
|
|
||||||
|
## STRATO VPS deployment notes
|
||||||
|
|
||||||
|
1. Install Docker Engine and Docker Compose plugin on the VPS.
|
||||||
|
2. Clone the repository on the server.
|
||||||
|
3. Create `.env` from `.env.example` and set a real domain in `NEXT_PUBLIC_SITE_URL`.
|
||||||
|
4. Run `make build` once, then `make start`.
|
||||||
|
5. Place Nginx or Caddy in front of the container for HTTPS and domain routing.
|
||||||
|
6. Point your domain to the VPS public IP and enable SSL at the reverse-proxy layer.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
- `APP_PORT`: external port exposed by Docker Compose
|
||||||
|
- `NEXT_PUBLIC_SITE_URL`: canonical site URL used by metadata and sitemap
|
||||||
|
- `NEXT_PUBLIC_SITE_MODE`: `coming-soon` or `full`
|
||||||
|
- `NEXT_PUBLIC_CONTACT_EMAIL`: public email shown on the contact page
|
||||||
|
- `NEXT_PUBLIC_LINKEDIN_URL`: LinkedIn profile URL
|
||||||
|
- `NEXT_PUBLIC_GITHUB_URL`: GitHub profile URL
|
||||||
@@ -1,19 +1,36 @@
|
|||||||
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { getActiveLocale, getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
||||||
|
import { buildPageMetadata } from "@/lib/metadata";
|
||||||
|
import { isSiteClosedMode } from "@/lib/site";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
|
||||||
|
if (!isLocale(params.locale)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildPageMetadata(getActiveLocale(params.locale), "about");
|
||||||
|
}
|
||||||
|
|
||||||
export default function AboutPage({ params }: { params: { locale: string } }) {
|
export default function AboutPage({ params }: { params: { locale: string } }) {
|
||||||
if (!isLocale(params.locale)) {
|
if (!isLocale(params.locale)) {
|
||||||
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 (
|
||||||
<section className="panel">
|
<section className="panel section-stack">
|
||||||
<p className="eyebrow">{dictionary.about.kicker}</p>
|
<div>
|
||||||
<h1>{dictionary.about.title}</h1>
|
<p className="eyebrow">{dictionary.about.kicker}</p>
|
||||||
<p className="lead">{dictionary.about.story}</p>
|
<h1>{dictionary.about.title}</h1>
|
||||||
|
<p className="lead">{dictionary.about.story}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="split-grid">
|
<div className="split-grid">
|
||||||
<article className="card">
|
<article className="card">
|
||||||
@@ -34,6 +51,15 @@ export default function AboutPage({ params }: { params: { locale: string } }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<article className="card">
|
||||||
|
<h2>{dictionary.about.principlesTitle}</h2>
|
||||||
|
<ul className="list">
|
||||||
|
{dictionary.about.principles.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,28 +1,84 @@
|
|||||||
import { getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { getActiveLocale, getDictionary, isLocale, type Locale } from "@/lib/i18n";
|
||||||
|
import { buildPageMetadata } from "@/lib/metadata";
|
||||||
|
import { getContactChannels, isSiteClosedMode } from "@/lib/site";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import ContactForm from "@/components/public/contact-form";
|
||||||
|
|
||||||
|
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
|
||||||
|
if (!isLocale(params.locale)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildPageMetadata(getActiveLocale(params.locale), "contact");
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContactPage({ params }: { params: { locale: string } }) {
|
export default function ContactPage({ params }: { params: { locale: string } }) {
|
||||||
if (!isLocale(params.locale)) {
|
if (!isLocale(params.locale)) {
|
||||||
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);
|
||||||
|
const channels = getContactChannels(dictionary.contact);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel">
|
<section className="panel section-stack">
|
||||||
<p className="eyebrow">{dictionary.contact.kicker}</p>
|
<div>
|
||||||
<h1>{dictionary.contact.title}</h1>
|
<p className="eyebrow">{dictionary.contact.kicker}</p>
|
||||||
<p className="lead">{dictionary.contact.description}</p>
|
<h1>{dictionary.contact.title}</h1>
|
||||||
|
<p className="lead">{dictionary.contact.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="split-grid">
|
<article className="card availability-card">
|
||||||
{dictionary.contact.channels.map((channel) => (
|
<h2>{dictionary.contact.availabilityTitle}</h2>
|
||||||
<article className="card" key={channel.name}>
|
<p>{dictionary.contact.availabilityDescription}</p>
|
||||||
<h2>{channel.name}</h2>
|
</article>
|
||||||
<p>{channel.status}</p>
|
|
||||||
<button type="button" className="pending-btn" disabled aria-disabled="true">
|
<ContactForm
|
||||||
{dictionary.contact.pendingCta}
|
labels={{
|
||||||
</button>
|
name: dictionary.contact.formName,
|
||||||
|
email: dictionary.contact.formEmail,
|
||||||
|
message: dictionary.contact.formMessage,
|
||||||
|
submit: dictionary.contact.formSubmit,
|
||||||
|
sending: dictionary.contact.formSending,
|
||||||
|
success: dictionary.contact.formSuccess,
|
||||||
|
savedWarning: dictionary.contact.formSavedWarning,
|
||||||
|
invalid: dictionary.contact.formInvalid,
|
||||||
|
rateLimit: dictionary.contact.formRateLimit,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="section-heading">
|
||||||
|
<h2>{dictionary.contact.channelsTitle}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="split-grid contact-grid">
|
||||||
|
{channels.map((channel) => (
|
||||||
|
<article className="card contact-channel-card" key={channel.key}>
|
||||||
|
<div className="channel-meta">
|
||||||
|
<h3>{channel.name}</h3>
|
||||||
|
<p>{channel.hint}</p>
|
||||||
|
</div>
|
||||||
|
<p className="contact-value">{channel.value}</p>
|
||||||
|
{channel.href ? (
|
||||||
|
<a
|
||||||
|
href={channel.href}
|
||||||
|
className="card-link"
|
||||||
|
target={channel.external ? "_blank" : undefined}
|
||||||
|
rel={channel.external ? "noreferrer" : undefined}
|
||||||
|
>
|
||||||
|
{dictionary.contact.channelCta}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="card-link is-disabled" aria-disabled="true">
|
||||||
|
{dictionary.contact.channelFallback}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</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} />;
|
||||||
|
}
|
||||||
+13
-4
@@ -1,12 +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 function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return locales.map((locale) => ({ locale }));
|
return getEnabledLocales().map((locale) => ({ locale }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LocaleLayout({
|
export default function LocaleLayout({
|
||||||
@@ -20,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} />;
|
||||||
|
}
|
||||||
+13
-3
@@ -1,14 +1,24 @@
|
|||||||
|
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 { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
export function generateMetadata({ params }: { params: { locale: string } }): Metadata {
|
||||||
|
if (!isLocale(params.locale)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildPageMetadata(getActiveLocale(params.locale), "home");
|
||||||
|
}
|
||||||
|
|
||||||
export default function HomePage({ params }: { params: { locale: string } }) {
|
export default function HomePage({ params }: { params: { locale: string } }) {
|
||||||
if (!isLocale(params.locale)) {
|
if (!isLocale(params.locale)) {
|
||||||
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} common={dictionary.common} />;
|
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,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+248
-10
@@ -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);
|
||||||
@@ -61,6 +65,7 @@ body {
|
|||||||
var(--bg);
|
var(--bg);
|
||||||
font-family: "Space Grotesk", "IBM Plex Sans Arabic", "Cairo", sans-serif;
|
font-family: "Space Grotesk", "IBM Plex Sans Arabic", "Cairo", sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -96,12 +101,55 @@ a {
|
|||||||
padding: 0.9rem 0;
|
padding: 0.9rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.18rem;
|
||||||
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-tagline {
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 80%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--surface-2) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav a {
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 180ms ease, color 180ms ease, transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-nav a:hover,
|
||||||
|
.header-nav a:focus-visible {
|
||||||
|
color: var(--text);
|
||||||
|
background: color-mix(in srgb, var(--surface-3) 86%, transparent);
|
||||||
|
outline: none;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: color-mix(in srgb, var(--surface-2) 88%, transparent);
|
background: color-mix(in srgb, var(--surface-2) 88%, transparent);
|
||||||
@@ -156,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);
|
||||||
@@ -164,6 +228,24 @@ a {
|
|||||||
padding: clamp(1.3rem, 2vw, 2rem);
|
padding: clamp(1.3rem, 2vw, 2rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.availability-badge {
|
||||||
|
margin: 0 0 0.7rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--brand-2) 28%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--surface-2) 88%, transparent);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.hero h1,
|
.hero h1,
|
||||||
.panel h1 {
|
.panel h1 {
|
||||||
font-size: clamp(1.8rem, 5vw, 3.3rem);
|
font-size: clamp(1.8rem, 5vw, 3.3rem);
|
||||||
@@ -194,8 +276,7 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cta-btn,
|
.cta-btn,
|
||||||
.ghost-btn,
|
.ghost-btn {
|
||||||
.pending-btn {
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
padding: 0.74rem 1rem;
|
padding: 0.74rem 1rem;
|
||||||
@@ -213,12 +294,10 @@ a {
|
|||||||
background: var(--button-secondary);
|
background: var(--button-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pending-btn {
|
.cta-btn.is-disabled {
|
||||||
margin-top: 0.6rem;
|
opacity: 0.7;
|
||||||
border-color: var(--line);
|
|
||||||
color: var(--muted);
|
|
||||||
background: var(--button-disabled);
|
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cta-btn:hover,
|
.cta-btn:hover,
|
||||||
@@ -246,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));
|
||||||
}
|
}
|
||||||
@@ -279,6 +424,61 @@ a {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-heading h2,
|
||||||
|
.availability-card h2,
|
||||||
|
.contact-channel-card h3 {
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-grid {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-channel-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-value {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.94rem;
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: fit-content;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.74rem 1rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
background: linear-gradient(115deg, var(--brand), var(--brand-2));
|
||||||
|
transition: transform 180ms ease, opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-link:hover,
|
||||||
|
.card-link:focus-visible {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-link.is-disabled {
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--button-disabled);
|
||||||
|
border-color: var(--line);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.list {
|
.list {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding-inline-start: 1.1rem;
|
padding-inline-start: 1.1rem;
|
||||||
@@ -300,6 +500,15 @@ a {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-copy p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.locale-badge {
|
.locale-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -379,9 +588,10 @@ a {
|
|||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-toggle {
|
@media (min-width: 861px) {
|
||||||
margin-inline-start: 0.18rem;
|
.bottom-nav {
|
||||||
border-color: var(--line) !important;
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-up {
|
@keyframes fade-up {
|
||||||
@@ -396,7 +606,35 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
|
.bar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
.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 |
+44
-7
@@ -1,14 +1,48 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { isSiteClosedMode, siteConfig } from "@/lib/site";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export function generateMetadata(): Metadata {
|
||||||
title: "diyaa | Personal Website",
|
const siteClosed = isSiteClosedMode();
|
||||||
description: "Bilingual personal website built with Next.js",
|
|
||||||
};
|
|
||||||
|
|
||||||
const themeScript = `
|
return {
|
||||||
|
metadataBase: new URL(siteConfig.siteUrl),
|
||||||
|
title: {
|
||||||
|
default: "Diyaa",
|
||||||
|
template: "%s | Diyaa",
|
||||||
|
},
|
||||||
|
description: siteClosed
|
||||||
|
? "Minimal coming soon page for the upcoming launch."
|
||||||
|
: "Bilingual professional website built for private-server deployment.",
|
||||||
|
applicationName: "Diyaa",
|
||||||
|
authors: [{ name: "Diyaa" }],
|
||||||
|
creator: "Diyaa",
|
||||||
|
publisher: "Diyaa",
|
||||||
|
alternates: {
|
||||||
|
languages: siteClosed
|
||||||
|
? {
|
||||||
|
en: "/",
|
||||||
|
"x-default": "/",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
ar: "/ar",
|
||||||
|
en: "/en",
|
||||||
|
"x-default": "/ar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getThemeScript() {
|
||||||
|
const siteClosed = isSiteClosedMode();
|
||||||
|
|
||||||
|
return `
|
||||||
(() => {
|
(() => {
|
||||||
|
const locale = ${siteClosed ? '"en"' : 'window.location.pathname.split("/").filter(Boolean)[0] === "en" ? "en" : "ar"'};
|
||||||
|
const direction = locale === "ar" ? "rtl" : "ltr";
|
||||||
|
document.documentElement.lang = locale;
|
||||||
|
document.documentElement.dir = direction;
|
||||||
try {
|
try {
|
||||||
const storedTheme = localStorage.getItem("theme");
|
const storedTheme = localStorage.getItem("theme");
|
||||||
const activeTheme = storedTheme === "light" || storedTheme === "dark" ? storedTheme : "dark";
|
const activeTheme = storedTheme === "light" || storedTheme === "dark" ? storedTheme : "dark";
|
||||||
@@ -18,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" 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>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { isSiteClosedMode } from "@/lib/site";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const siteClosed = isSiteClosedMode();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-content">
|
||||||
|
<section className="panel section-stack">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">404</p>
|
||||||
|
<h1>الصفحة غير موجودة</h1>
|
||||||
|
<p className="lead">
|
||||||
|
{siteClosed
|
||||||
|
? "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى الصفحة الرئيسية."
|
||||||
|
: "الصفحة المطلوبة غير متاحة حاليًا. يمكنك العودة إلى النسخة العربية أو الإنجليزية من الصفحة الرئيسية."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="cta-row">
|
||||||
|
{siteClosed ? (
|
||||||
|
<Link href="/" className="cta-btn">
|
||||||
|
العودة إلى الرئيسية
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link href="/ar" className="cta-btn">
|
||||||
|
العودة إلى العربية
|
||||||
|
</Link>
|
||||||
|
<Link href="/en" className="ghost-btn">
|
||||||
|
Go to English
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 68 B |
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { siteConfig } from "@/lib/site";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
},
|
||||||
|
sitemap: `${siteConfig.siteUrl}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
import { getLocalizedPath, isSiteClosedMode, siteConfig } from "@/lib/site";
|
||||||
|
import type { Locale } from "@/lib/i18n";
|
||||||
|
|
||||||
|
const allPages = ["", "/about", "/contact", "/legal/privacy", "/legal/terms", "/legal/impressum"] as const;
|
||||||
|
const allLocales = ["ar", "en"] as const;
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const lastModified = new Date();
|
||||||
|
const siteClosed = isSiteClosedMode();
|
||||||
|
const pages: readonly (typeof allPages)[number][] = siteClosed ? [] : allPages;
|
||||||
|
const locales: readonly Locale[] = siteClosed ? [] : allLocales;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: siteConfig.siteUrl,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "weekly",
|
||||||
|
priority: 0.8,
|
||||||
|
},
|
||||||
|
...pages.flatMap((page) =>
|
||||||
|
locales.map((locale) => ({
|
||||||
|
url: `${siteConfig.siteUrl}${getLocalizedPath(page, locale)}`,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "weekly" as const,
|
||||||
|
priority: page === "" ? 1 : 0.7,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-26
@@ -3,7 +3,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import type { CommonContent } from "@/content/types";
|
import type { CommonContent } from "@/content/types";
|
||||||
import { locales, type Locale } from "@/lib/i18n";
|
import { type Locale } from "@/lib/i18n";
|
||||||
|
import { isComingSoonMode } from "@/lib/site";
|
||||||
|
|
||||||
type BottomNavProps = {
|
type BottomNavProps = {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
@@ -12,33 +13,31 @@ type BottomNavProps = {
|
|||||||
|
|
||||||
export default function BottomNav({ locale, common }: BottomNavProps) {
|
export default function BottomNav({ locale, common }: BottomNavProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const segments = pathname.split("/").filter(Boolean);
|
|
||||||
|
if (isComingSoonMode()) {
|
||||||
|
return (
|
||||||
|
<nav aria-label={common.navLabel} className="bottom-nav">
|
||||||
|
<div className="container bottom-nav-inner">
|
||||||
|
<div className="bottom-nav-links">
|
||||||
|
<Link href={`/${locale}`} className="active" aria-current="page">
|
||||||
|
<span aria-hidden className="nav-icon">
|
||||||
|
○
|
||||||
|
</span>
|
||||||
|
{common.nav.home}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedPath = pathname !== "/" && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
const normalizedPath = pathname !== "/" && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
|
||||||
const homePath = `/${locale}`;
|
const homePath = `/${locale}`;
|
||||||
const aboutPath = `/${locale}/about`;
|
const aboutPath = `/${locale}/about`;
|
||||||
const contactPath = `/${locale}/contact`;
|
const contactPath = `/${locale}/contact`;
|
||||||
|
|
||||||
const isHome = normalizedPath === homePath;
|
const isHome = normalizedPath === homePath;
|
||||||
const isAbout = normalizedPath === aboutPath || normalizedPath.startsWith(`${aboutPath}/`);
|
const isAbout = normalizedPath === aboutPath || normalizedPath.startsWith(`${aboutPath}/`);
|
||||||
const isContact = normalizedPath === contactPath || normalizedPath.startsWith(`${contactPath}/`);
|
const isContact = normalizedPath === contactPath || normalizedPath.startsWith(`${contactPath}/`);
|
||||||
const targetLocale: Locale = locale === "ar" ? "en" : "ar";
|
|
||||||
|
|
||||||
const switchPath = () => {
|
|
||||||
const nextSegments = [...segments];
|
|
||||||
|
|
||||||
if (nextSegments.length === 0) {
|
|
||||||
return `/${targetLocale}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (locales.includes(nextSegments[0] as Locale)) {
|
|
||||||
nextSegments[0] = targetLocale;
|
|
||||||
} else {
|
|
||||||
nextSegments.unshift(targetLocale);
|
|
||||||
}
|
|
||||||
|
|
||||||
return `/${nextSegments.join("/")}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav aria-label={common.navLabel} className="bottom-nav">
|
<nav aria-label={common.navLabel} className="bottom-nav">
|
||||||
@@ -66,12 +65,6 @@ export default function BottomNav({ locale, common }: BottomNavProps) {
|
|||||||
</span>
|
</span>
|
||||||
{common.nav.contact}
|
{common.nav.contact}
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={switchPath()} className="lang-toggle" aria-label={common.languageSwitcherLabel}>
|
|
||||||
<span aria-hidden className="nav-icon">
|
|
||||||
⇄
|
|
||||||
</span>
|
|
||||||
{targetLocale.toUpperCase()}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+27
-11
@@ -1,31 +1,47 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { CommonContent, HomeContent } from "@/content/types";
|
import ComingSoonPanel from "@/components/ComingSoonPanel";
|
||||||
|
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";
|
||||||
|
|
||||||
type HeroSectionProps = {
|
type HeroSectionProps = {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
home: HomeContent;
|
home: HomeContent;
|
||||||
common: CommonContent;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function HeroSection({ locale, home, common }: HeroSectionProps) {
|
export default function HeroSection({ locale, home }: HeroSectionProps) {
|
||||||
|
const activeHome: HomeVariantContent = getModeValue(home);
|
||||||
|
const comingSoon = isComingSoonMode();
|
||||||
|
const emailHref = getEmailHref();
|
||||||
|
|
||||||
|
if (comingSoon) {
|
||||||
|
return <ComingSoonPanel content={activeHome} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="hero panel">
|
<section className="hero panel">
|
||||||
<p className="eyebrow">{home.kicker}</p>
|
<p className="availability-badge">{activeHome.badge}</p>
|
||||||
<h1>{home.title}</h1>
|
<p className="eyebrow">{activeHome.kicker}</p>
|
||||||
<p className="lead">{home.description}</p>
|
<h1>{activeHome.title}</h1>
|
||||||
|
<p className="lead">{activeHome.description}</p>
|
||||||
|
|
||||||
<div className="cta-row">
|
<div className="cta-row">
|
||||||
<Link href={`/${locale}/contact`} className="cta-btn">
|
{emailHref ? (
|
||||||
{home.primaryCta}
|
<a href={emailHref} className="cta-btn">
|
||||||
</Link>
|
{activeHome.primaryCta}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="cta-btn is-disabled" aria-disabled="true">
|
||||||
|
{activeHome.primaryCta}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Link href={`/${locale}/about`} className="ghost-btn">
|
<Link href={`/${locale}/about`} className="ghost-btn">
|
||||||
{common.nav.about}
|
{activeHome.secondaryCta}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stat-grid">
|
<div className="stat-grid">
|
||||||
{home.highlights.map((item) => (
|
{activeHome.highlights.map((item) => (
|
||||||
<article key={item.label} className="stat-card">
|
<article key={item.label} className="stat-card">
|
||||||
<span className="stat-value">{item.value}</span>
|
<span className="stat-value">{item.value}</span>
|
||||||
<span className="stat-label">{item.label}</span>
|
<span className="stat-label">{item.label}</span>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function LanguageSwitcher({ locale, label }: LanguageSwitcherProp
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="language-switcher" aria-label={label}>
|
<div className="language-switcher" role="group" aria-label={label}>
|
||||||
{locales.map((item) => {
|
{locales.map((item) => {
|
||||||
const active = item === locale;
|
const active = item === locale;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +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, isComingSoonMode } from "@/lib/site";
|
||||||
|
|
||||||
type SiteFooterProps = {
|
type SiteFooterProps = {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
@@ -8,12 +10,26 @@ 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 isComingSoon = isComingSoonMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="site-footer">
|
<footer className="site-footer">
|
||||||
<div className="container footer-content">
|
<div className="container footer-content">
|
||||||
<p>{common.footerRights.replace("{year}", String(year))}</p>
|
<div className="footer-copy">
|
||||||
<p className="locale-badge">{locale.toUpperCase()}</p>
|
<p>{common.footerRights.replace("{year}", String(year))}</p>
|
||||||
|
<p>{commonVariant.footerBuiltWith}</p>
|
||||||
|
</div>
|
||||||
|
{!isComingSoon ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<nav aria-label={common.legal.label} className="flex flex-wrap gap-3 text-sm">
|
||||||
|
<Link href={`/${locale}/legal/privacy`} className="hover:text-foreground hover:underline">{common.legal.privacy}</Link>
|
||||||
|
<Link href={`/${locale}/legal/terms`} className="hover:text-foreground hover:underline">{common.legal.terms}</Link>
|
||||||
|
<Link href={`/${locale}/legal/impressum`} className="hover:text-foreground hover:underline">{common.legal.impressum}</Link>
|
||||||
|
</nav>
|
||||||
|
<p className="locale-badge">{locale.toUpperCase()}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||||
import ThemeToggle from "@/components/ThemeToggle";
|
import ThemeToggle from "@/components/ThemeToggle";
|
||||||
import type { Locale } from "@/lib/i18n";
|
import type { Locale } from "@/lib/i18n";
|
||||||
import type { CommonContent } from "@/content/types";
|
import type { CommonContent } from "@/content/types";
|
||||||
|
import { getModeValue, isComingSoonMode } from "@/lib/site";
|
||||||
|
|
||||||
type SiteHeaderProps = {
|
type SiteHeaderProps = {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
@@ -9,13 +11,38 @@ type SiteHeaderProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SiteHeader({ locale, common }: SiteHeaderProps) {
|
export default function SiteHeader({ locale, common }: SiteHeaderProps) {
|
||||||
|
const isComingSoon = isComingSoonMode();
|
||||||
|
const commonVariant = getModeValue(common.variants);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
<div className="container bar">
|
<div className="container bar">
|
||||||
<Link href={`/${locale}`} className="brand">
|
<div className="brand-block">
|
||||||
{common.siteTitle}
|
<Link href={`/${locale}`} className="brand">
|
||||||
</Link>
|
{common.siteTitle}
|
||||||
<ThemeToggle />
|
</Link>
|
||||||
|
<span className="brand-tagline">{commonVariant.siteTagline}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isComingSoon ? (
|
||||||
|
<nav className="header-nav" aria-label={common.navLabel}>
|
||||||
|
<Link href={`/${locale}`}>{common.nav.home}</Link>
|
||||||
|
<Link href={`/${locale}/work`}>{common.nav.projects}</Link>
|
||||||
|
<Link href={`/${locale}/apps`}>{common.nav.apps}</Link>
|
||||||
|
<Link href={`/${locale}/melodies`}>{common.nav.melodies}</Link>
|
||||||
|
<Link href={`/${locale}/about`}>{common.nav.about}</Link>
|
||||||
|
<Link href={`/${locale}/contact`}>{common.nav.contact}</Link>
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
|
{!isComingSoon ? <LanguageSwitcher locale={locale} label={common.languageSwitcherLabel} /> : null}
|
||||||
|
<ThemeToggle
|
||||||
|
label={common.themeToggleLabel}
|
||||||
|
lightLabel={common.themeLight}
|
||||||
|
darkLabel={common.themeDark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ type Theme = "dark" | "light";
|
|||||||
|
|
||||||
const STORAGE_KEY = "theme";
|
const STORAGE_KEY = "theme";
|
||||||
|
|
||||||
|
type ThemeToggleProps = {
|
||||||
|
label: string;
|
||||||
|
lightLabel: string;
|
||||||
|
darkLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
function isTheme(value: string | null): value is Theme {
|
function isTheme(value: string | null): value is Theme {
|
||||||
return value === "dark" || value === "light";
|
return value === "dark" || value === "light";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ThemeToggle() {
|
export default function ThemeToggle({ label, lightLabel, darkLabel }: ThemeToggleProps) {
|
||||||
const [theme, setTheme] = useState<Theme>("dark");
|
const [theme, setTheme] = useState<Theme>("dark");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,9 +40,9 @@ export default function ThemeToggle() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
className="theme-toggle"
|
className="theme-toggle"
|
||||||
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
aria-label={`${label}: ${theme === "dark" ? lightLabel : darkLabel}`}
|
||||||
>
|
>
|
||||||
<span className="theme-toggle-label">{theme === "dark" ? "Light" : "Dark"}</span>
|
<span className="theme-toggle-label">{theme === "dark" ? lightLabel : darkLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
+107
-22
@@ -5,50 +5,135 @@ const ar: Dictionary = {
|
|||||||
siteTitle: "ضياء",
|
siteTitle: "ضياء",
|
||||||
navLabel: "التنقل الرئيسي",
|
navLabel: "التنقل الرئيسي",
|
||||||
languageSwitcherLabel: "تبديل اللغة",
|
languageSwitcherLabel: "تبديل اللغة",
|
||||||
|
themeToggleLabel: "نمط الواجهة",
|
||||||
|
themeLight: "فاتح",
|
||||||
|
themeDark: "داكن",
|
||||||
nav: {
|
nav: {
|
||||||
home: "الرئيسية",
|
home: "الرئيسية",
|
||||||
|
projects: "المشاريع",
|
||||||
|
apps: "التطبيقات",
|
||||||
|
melodies: "الألحان",
|
||||||
about: "من أنا",
|
about: "من أنا",
|
||||||
contact: "تواصل",
|
contact: "تواصل",
|
||||||
},
|
},
|
||||||
|
legal: {
|
||||||
|
label: "الصفحات الرسمية",
|
||||||
|
privacy: "الخصوصية",
|
||||||
|
terms: "شروط الاستخدام",
|
||||||
|
impressum: "Impressum · بيانات المزوّد",
|
||||||
|
},
|
||||||
footerRights: "{year} جميع الحقوق محفوظة",
|
footerRights: "{year} جميع الحقوق محفوظة",
|
||||||
|
variants: {
|
||||||
|
comingSoon: {
|
||||||
|
siteTagline: "موقع شخصي جديد قيد الإطلاق",
|
||||||
|
footerBuiltWith: "صفحة مؤقتة للإعلان عن قرب إطلاق الموقع الكامل على الخادم الخاص.",
|
||||||
|
},
|
||||||
|
full: {
|
||||||
|
siteTagline: "مطور ويب يبني تجارب سريعة وقابلة للتوسع",
|
||||||
|
footerBuiltWith: "موقع ثنائي اللغة مهيأ للعرض المهني والنشر على خادم خاص.",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
kicker: "Full-Stack Developer",
|
comingSoon: {
|
||||||
title: "أبني أنظمة ويب حديثة وقابلة للتوسع",
|
badge: "Coming Soon",
|
||||||
description:
|
kicker: "قريبًا",
|
||||||
"مطور Full-Stack أعمل على بناء تطبيقات ويب حديثة باستخدام Next.js و NestJS و PostgreSQL. أركز على الأنظمة القابلة للتوسع وتجارب المستخدم السريعة.",
|
title: "موقعي الجديد قيد التجهيز",
|
||||||
primaryCta: "تواصل معي",
|
description:
|
||||||
highlights: [
|
"أعمل حاليًا على إطلاق نسخة جديدة بهوية أوضح وتجربة أسرع ومحتوى أكثر اكتمالًا. الصفحة الكاملة ستتوفر قريبًا بعد إنهاء اللمسات الأخيرة.",
|
||||||
{ value: "+10", label: "مشاريع برمجية" },
|
primaryCta: "تواصل عند الإطلاق",
|
||||||
{ value: "Next.js / NestJS", label: "Stack الأساسي" },
|
secondaryCta: "تابع قريبًا",
|
||||||
{ value: "Docker", label: "Dev Infrastructure" },
|
highlights: [
|
||||||
],
|
{ value: "نسخة جديدة", label: "واجهة ومحتوى محدثان" },
|
||||||
|
{ value: "ثنائي اللغة", label: "عربي وإنجليزي" },
|
||||||
|
{ value: "قريبًا", label: "الإطلاق الرسمي" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
full: {
|
||||||
|
badge: "متاح لمشاريع الويب والتطوير المخصص",
|
||||||
|
kicker: "مطور ويب متكامل",
|
||||||
|
title: "أبني مواقع وأنظمة ويب جاهزة للنمو",
|
||||||
|
description:
|
||||||
|
"أصمم وأطوّر واجهات وتجارب رقمية متماسكة، وأربطها ببنية تشغيل واضحة تجعل الإطلاق والصيانة والتوسع أسهل على المدى الطويل.",
|
||||||
|
primaryCta: "ابدأ التواصل",
|
||||||
|
secondaryCta: "اعرف المزيد",
|
||||||
|
highlights: [
|
||||||
|
{ value: "+10", label: "نماذج ومشاريع مطورة" },
|
||||||
|
{ value: "ثنائي اللغة", label: "عربي وإنجليزي" },
|
||||||
|
{ value: "جاهز للنشر", label: "Docker و VPS" },
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
kicker: "نبذة سريعة",
|
kicker: "نبذة سريعة",
|
||||||
title: "من أنا",
|
title: "من أنا",
|
||||||
story:
|
story:
|
||||||
"أنا مطور Full-Stack أركز على بناء تطبيقات ويب حديثة وقابلة للتوسع. أعمل بشكل أساسي باستخدام Next.js و NestJS و PostgreSQL مع بنية تشغيل تعتمد على Docker. أركز في مشاريعي على الأداء، البنية النظيفة، وتجربة المستخدم السريعة، مع تصميم أنظمة يمكن تطويرها بسهولة مع نمو المشروع.",
|
"أعمل على تحويل الأفكار إلى مواقع ومنتجات ويب واضحة وسريعة ومرنة. تركيزي الأساسي يكون على الجمع بين تجربة استخدام مرتبة، كود منظم، وتجهيز فعلي للنشر حتى لا يبقى المشروع جميلًا فقط بل قابلًا للاعتماد والتطوير لاحقًا.",
|
||||||
skillsTitle: "مهارات",
|
skillsTitle: "مهارات",
|
||||||
skills: ["تطوير واجهات", "تحسين تجربة المستخدم", "تنظيم المحتوى", "إدارة مشاريع رقمية"],
|
skills: [
|
||||||
|
"Next.js و React",
|
||||||
|
"TypeScript وتنظيم الواجهات",
|
||||||
|
"تصميم واجهات مرنة وسريعة",
|
||||||
|
"تهيئة النشر على الخوادم الخاصة",
|
||||||
|
"تحسين الأداء وتجربة الاستخدام",
|
||||||
|
"تنظيم المحتوى متعدد اللغات",
|
||||||
|
],
|
||||||
experienceTitle: "خبرات مختصرة",
|
experienceTitle: "خبرات مختصرة",
|
||||||
experience: [
|
experience: [
|
||||||
"HTML",
|
"بناء مواقع تعريفية وصفحات هبوط وهوية رقمية مهنية",
|
||||||
"CSS , SCSS",
|
"تطوير واجهات تطبيقات حديثة قابلة للتوسع",
|
||||||
"React",
|
"ربط الواجهة بتدفق نشر واضح باستخدام Docker",
|
||||||
|
],
|
||||||
|
principlesTitle: "منهج العمل",
|
||||||
|
principles: [
|
||||||
|
"بنية واضحة تسهّل التوسع والصيانة لاحقًا",
|
||||||
|
"اهتمام مبكر بالأداء ووضوح الرسالة داخل الواجهة",
|
||||||
|
"تجهيز عملي للنشر، الصحة التشغيلية، والاعتماد على ملفات البيئة",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
contact: {
|
contact: {
|
||||||
kicker: "لنبدأ خطوة جديدة",
|
kicker: "لنبدأ مشروعًا واضحًا وقابلًا للتنفيذ",
|
||||||
title: "تواصل",
|
title: "قنوات التواصل",
|
||||||
description: "روابط التواصل ستتم إضافتها لاحقًا. كل العناصر أدناه جاهزة لاستقبال الروابط الفعلية.",
|
description: "بمجرد إضافة روابطك الحقيقية في ملف البيئة ستظهر هنا تلقائيًا وتصبح جاهزة للاستخدام المباشر من الزوار.",
|
||||||
pendingCta: "الرابط قيد الإضافة",
|
availabilityTitle: "جاهزية الإطلاق",
|
||||||
|
availabilityDescription:
|
||||||
|
"النسخة الحالية مهيأة للنشر على خادم خاص مع فحص صحة داخلي وروابط تواصل قابلة للإدارة من الإعدادات.",
|
||||||
|
channelsTitle: "روابطك المباشرة",
|
||||||
|
channelCta: "فتح الرابط",
|
||||||
|
channelFallback: "ستظهر القناة هنا بعد إضافة الرابط الفعلي",
|
||||||
|
formName: "الاسم",
|
||||||
|
formEmail: "البريد الإلكتروني",
|
||||||
|
formMessage: "الرسالة",
|
||||||
|
formSubmit: "إرسال الرسالة",
|
||||||
|
formSending: "جارٍ الإرسال…",
|
||||||
|
formSuccess: "تم استلام رسالتك، وسأعود إليك قريباً.",
|
||||||
|
formSavedWarning: "تم حفظ رسالتك، لكن تعذّر إرسال إشعار البريد حالياً.",
|
||||||
|
formInvalid: "تحقق من الاسم والبريد والرسالة وحاول مجدداً.",
|
||||||
|
formRateLimit: "تم تجاوز الحد المؤقت للإرسال. حاول بعد قليل.",
|
||||||
channels: [
|
channels: [
|
||||||
{ name: "Email", status: "سيتم إضافة الرابط قريبًا" },
|
{ key: "email", name: "البريد الإلكتروني", hint: "للتواصل المهني المباشر والاستفسارات" },
|
||||||
{ name: "LinkedIn", status: "سيتم إضافة الرابط قريبًا" },
|
{ key: "linkedin", name: "لينكدإن", hint: "للعلاقات المهنية والعرض السريع للخبرات" },
|
||||||
{ name: "GitHub", status: "سيتم إضافة الرابط قريبًا" },
|
{ key: "github", name: "جيت هب", hint: "لاستعراض المشاريع والمستودعات البرمجية" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
home: {
|
||||||
|
comingSoon: {
|
||||||
|
title: "قريبًا",
|
||||||
|
description: "صفحة ترحيبية مؤقتة للإعلان عن قرب إطلاق الموقع الشخصي الجديد.",
|
||||||
|
},
|
||||||
|
full: {
|
||||||
|
title: "الصفحة الرئيسية",
|
||||||
|
description: "موقع شخصي احترافي ثنائي اللغة يعرض الهوية المهنية والخبرة التقنية والاستعداد للنشر.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutTitle: "من أنا",
|
||||||
|
aboutDescription: "تعرف على الخبرات، المهارات، ومنهج العمل في بناء مواقع وأنظمة ويب حديثة.",
|
||||||
|
contactTitle: "تواصل",
|
||||||
|
contactDescription: "صفحة تواصل جاهزة لعرض البريد وروابط المنصات المهنية مباشرة من الإعدادات.",
|
||||||
|
notFoundTitle: "الصفحة غير موجودة",
|
||||||
|
notFoundDescription: "تعذر العثور على الصفحة المطلوبة. يمكنك العودة إلى الصفحة الرئيسية أو التبديل بين اللغتين.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ar;
|
export default ar;
|
||||||
|
|||||||
+109
-24
@@ -2,53 +2,138 @@ import type { Dictionary } from "@/content/types";
|
|||||||
|
|
||||||
const en: Dictionary = {
|
const en: Dictionary = {
|
||||||
common: {
|
common: {
|
||||||
siteTitle: "diyaa | Personal Site",
|
siteTitle: "Diyaa",
|
||||||
navLabel: "Main navigation",
|
navLabel: "Main navigation",
|
||||||
languageSwitcherLabel: "Switch language",
|
languageSwitcherLabel: "Switch language",
|
||||||
|
themeToggleLabel: "Interface theme",
|
||||||
|
themeLight: "Light",
|
||||||
|
themeDark: "Dark",
|
||||||
nav: {
|
nav: {
|
||||||
home: "Home",
|
home: "Home",
|
||||||
about: "About Me",
|
projects: "Projects",
|
||||||
|
apps: "Apps",
|
||||||
|
melodies: "Melodies",
|
||||||
|
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: {
|
||||||
|
comingSoon: {
|
||||||
|
siteTagline: "A new personal site is on the way",
|
||||||
|
footerBuiltWith: "A temporary landing page announcing the upcoming launch on the private server.",
|
||||||
|
},
|
||||||
|
full: {
|
||||||
|
siteTagline: "Web developer building fast, scalable digital experiences",
|
||||||
|
footerBuiltWith: "A bilingual professional site prepared for deployment on a private server.",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
home: {
|
home: {
|
||||||
kicker: "Fresh personal presence",
|
comingSoon: {
|
||||||
title: "A bold digital space for my professional identity",
|
badge: "Coming Soon",
|
||||||
description:
|
kicker: "Temporary notice",
|
||||||
"This is placeholder copy you can replace quickly. It is built to highlight your profile, communicate value, and stay clean on every screen size.",
|
title: "This website is temporarily closed while a new version is prepared",
|
||||||
primaryCta: "Start a conversation",
|
description:
|
||||||
highlights: [
|
"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.",
|
||||||
{ value: "3", label: "Core pages" },
|
primaryCta: "Contact by email",
|
||||||
{ value: "RTL/LTR", label: "Direction ready" },
|
secondaryCta: "Stay tuned",
|
||||||
{ value: "100%", label: "Customizable" },
|
highlights: [
|
||||||
],
|
{ value: "Temporarily offline", label: "Public pages are paused for now" },
|
||||||
|
{ value: "New version", label: "Refined content and visual identity" },
|
||||||
|
{ value: "Soon", label: "Full launch will return shortly" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
full: {
|
||||||
|
badge: "Available for web builds and custom development",
|
||||||
|
kicker: "Full-stack web developer",
|
||||||
|
title: "I build websites and web systems ready to grow",
|
||||||
|
description:
|
||||||
|
"I design and ship cohesive digital experiences backed by clean implementation and deployment-ready structure, so launch and maintenance stay manageable over time.",
|
||||||
|
primaryCta: "Start a conversation",
|
||||||
|
secondaryCta: "Learn more",
|
||||||
|
highlights: [
|
||||||
|
{ value: "10+", label: "Projects and product builds" },
|
||||||
|
{ value: "Bilingual", label: "Arabic and English ready" },
|
||||||
|
{ value: "Deployable", label: "Docker and VPS workflow" },
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
kicker: "Short introduction",
|
kicker: "Short introduction",
|
||||||
title: "About me",
|
title: "About me",
|
||||||
story:
|
story:
|
||||||
"This is temporary biography text. Replace it with your real story, strengths, and the kind of outcomes you deliver through your work.",
|
"My focus is turning ideas into web products that feel polished in the browser and dependable in production. I care about clean interfaces, maintainable structure, and deployment choices that make future growth easier instead of harder.",
|
||||||
skillsTitle: "Skills",
|
skillsTitle: "Skills",
|
||||||
skills: ["Frontend engineering", "UX refinement", "Content structuring", "Digital project delivery"],
|
skills: [
|
||||||
|
"Next.js and React",
|
||||||
|
"TypeScript-driven UI structure",
|
||||||
|
"Fast, adaptable frontend systems",
|
||||||
|
"Private-server deployment setup",
|
||||||
|
"Performance and UX refinement",
|
||||||
|
"Bilingual content architecture",
|
||||||
|
],
|
||||||
experienceTitle: "Experience highlights",
|
experienceTitle: "Experience highlights",
|
||||||
experience: [
|
experience: [
|
||||||
"Placeholder experience line 1",
|
"Professional landing pages and personal web presence builds",
|
||||||
"Placeholder experience line 2",
|
"Scalable frontend interfaces for modern web apps",
|
||||||
"Placeholder experience line 3",
|
"Docker-based delivery flows for cleaner deployment",
|
||||||
|
],
|
||||||
|
principlesTitle: "Working principles",
|
||||||
|
principles: [
|
||||||
|
"Keep the structure clear enough for future expansion",
|
||||||
|
"Treat performance and clarity as part of the product",
|
||||||
|
"Prepare for deployment and operations from the beginning",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
contact: {
|
contact: {
|
||||||
kicker: "Let us connect",
|
kicker: "Let us start with a clear and actionable conversation",
|
||||||
title: "Contact",
|
title: "Contact channels",
|
||||||
description: "Your real contact links will be added later. The placeholders below are ready for direct replacement.",
|
description: "As soon as you add your real links in the environment file, they appear here automatically and become usable for visitors.",
|
||||||
pendingCta: "Link pending",
|
availabilityTitle: "Launch readiness",
|
||||||
|
availabilityDescription:
|
||||||
|
"This version is prepared for private-server deployment with an internal health endpoint and contact links managed through configuration.",
|
||||||
|
channelsTitle: "Direct contact links",
|
||||||
|
channelCta: "Open link",
|
||||||
|
channelFallback: "This channel will appear once the real link is added",
|
||||||
|
formName: "Name",
|
||||||
|
formEmail: "Email",
|
||||||
|
formMessage: "Message",
|
||||||
|
formSubmit: "Send message",
|
||||||
|
formSending: "Sending…",
|
||||||
|
formSuccess: "Your message was received. 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: [
|
||||||
{ name: "Email", status: "Real link will be added soon" },
|
{ key: "email", name: "Email", hint: "For direct professional contact and project inquiries" },
|
||||||
//{ name: "LinkedIn", status: "Real link will be added soon" },
|
{ key: "linkedin", name: "LinkedIn", hint: "For professional background and networking" },
|
||||||
//{ name: "GitHub", status: "Real link will be added soon" },
|
{ key: "github", name: "GitHub", hint: "For repositories, code samples, and project history" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
metadata: {
|
||||||
|
home: {
|
||||||
|
comingSoon: {
|
||||||
|
title: "Coming soon",
|
||||||
|
description: "A temporary landing page announcing the upcoming launch of the new personal website.",
|
||||||
|
},
|
||||||
|
full: {
|
||||||
|
title: "Home",
|
||||||
|
description: "A bilingual professional website presenting technical experience, web development services, and deployment readiness.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
aboutTitle: "About",
|
||||||
|
aboutDescription: "Learn more about experience, skills, and the working principles behind building modern web products.",
|
||||||
|
contactTitle: "Contact",
|
||||||
|
contactDescription: "A contact page ready to surface email and professional profile links directly from configuration.",
|
||||||
|
notFoundTitle: "Page not found",
|
||||||
|
notFoundDescription: "The page you requested could not be found. You can return to the home page or switch languages.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
+67
-6
@@ -1,26 +1,53 @@
|
|||||||
|
export type ModeVariants<T> = {
|
||||||
|
comingSoon: T;
|
||||||
|
full: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CommonVariantContent = {
|
||||||
|
siteTagline: string;
|
||||||
|
footerBuiltWith: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CommonContent = {
|
export type CommonContent = {
|
||||||
siteTitle: string;
|
siteTitle: string;
|
||||||
navLabel: string;
|
navLabel: string;
|
||||||
languageSwitcherLabel: string;
|
languageSwitcherLabel: string;
|
||||||
|
themeToggleLabel: string;
|
||||||
|
themeLight: 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>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HomeContent = {
|
export type HomeVariantContent = {
|
||||||
|
badge: string;
|
||||||
kicker: string;
|
kicker: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
primaryCta: string;
|
primaryCta: string;
|
||||||
|
secondaryCta: string;
|
||||||
highlights: Array<{
|
highlights: Array<{
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HomeContent = ModeVariants<HomeVariantContent>;
|
||||||
|
|
||||||
export type AboutContent = {
|
export type AboutContent = {
|
||||||
kicker: string;
|
kicker: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -29,17 +56,50 @@ export type AboutContent = {
|
|||||||
skills: string[];
|
skills: string[];
|
||||||
experienceTitle: string;
|
experienceTitle: string;
|
||||||
experience: string[];
|
experience: string[];
|
||||||
|
principlesTitle: string;
|
||||||
|
principles: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContactChannelContent = {
|
||||||
|
key: "email" | "linkedin" | "github";
|
||||||
|
name: string;
|
||||||
|
hint: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ContactContent = {
|
export type ContactContent = {
|
||||||
kicker: string;
|
kicker: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
pendingCta: string;
|
availabilityTitle: string;
|
||||||
channels: Array<{
|
availabilityDescription: string;
|
||||||
name: string;
|
channelsTitle: string;
|
||||||
status: string;
|
channelCta: string;
|
||||||
}>;
|
channelFallback: string;
|
||||||
|
formName: string;
|
||||||
|
formEmail: string;
|
||||||
|
formMessage: string;
|
||||||
|
formSubmit: string;
|
||||||
|
formSending: string;
|
||||||
|
formSuccess: string;
|
||||||
|
formSavedWarning: string;
|
||||||
|
formInvalid: string;
|
||||||
|
formRateLimit: string;
|
||||||
|
channels: ContactChannelContent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MetadataVariantContent = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MetadataContent = {
|
||||||
|
home: ModeVariants<MetadataVariantContent>;
|
||||||
|
aboutTitle: string;
|
||||||
|
aboutDescription: string;
|
||||||
|
contactTitle: string;
|
||||||
|
contactDescription: string;
|
||||||
|
notFoundTitle: string;
|
||||||
|
notFoundDescription: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Dictionary = {
|
export type Dictionary = {
|
||||||
@@ -47,4 +107,5 @@ export type Dictionary = {
|
|||||||
home: HomeContent;
|
home: HomeContent;
|
||||||
about: AboutContent;
|
about: AboutContent;
|
||||||
contact: ContactContent;
|
contact: ContactContent;
|
||||||
|
metadata: MetadataContent;
|
||||||
};
|
};
|
||||||
|
|||||||
+62
-8
@@ -1,23 +1,77 @@
|
|||||||
name: ${COMPOSE_PROJECT_NAME:-diyaa}
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: ${CONTAINER_NAME:-diyaa}
|
args:
|
||||||
ports:
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://diyaa.de}
|
||||||
- "${APP_PORT:-30002}:3000"
|
NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon}
|
||||||
env_file:
|
NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
|
||||||
- .env
|
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
|
||||||
|
NEXT_PUBLIC_GITHUB_URL: ${NEXT_PUBLIC_GITHUB_URL:-}
|
||||||
|
container_name: ${CONTAINER_NAME:-diyaa-website}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: ${NODE_ENV:-production}
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
HOSTNAME: 0.0.0.0
|
HOSTNAME: 0.0.0.0
|
||||||
|
NEXT_TELEMETRY_DISABLED: ${NEXT_TELEMETRY_DISABLED:-1}
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-diyaa}:${POSTGRES_PASSWORD:-change-me}@postgres:5432/${POSTGRES_DB:-diyaa}?schema=public
|
||||||
|
UPLOAD_DIR: ${UPLOAD_DIR:-/app/uploads}
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET:-}
|
||||||
|
AUTH_URL: ${AUTH_URL:-https://diyaa.de}
|
||||||
|
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-https://diyaa.de}
|
||||||
|
NEXT_PUBLIC_SITE_MODE: ${NEXT_PUBLIC_SITE_MODE:-coming-soon}
|
||||||
|
NEXT_PUBLIC_CONTACT_EMAIL: ${NEXT_PUBLIC_CONTACT_EMAIL:-}
|
||||||
|
NEXT_PUBLIC_LINKEDIN_URL: ${NEXT_PUBLIC_LINKEDIN_URL:-}
|
||||||
|
NEXT_PUBLIC_GITHUB_URL: ${NEXT_PUBLIC_GITHUB_URL:-}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000 || exit 1"]
|
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/health || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
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.
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user