feat: full site build — Project/Melody schema (Option A), admin CRUD, public sections, uploads, email+SMTP, internal analytics, legal pages, docs
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user