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:
2026-08-05 20:53:40 +02:00
parent d87f3033c6
commit c26f41511f
122 changed files with 15068 additions and 41 deletions
+11 -1
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import type { Locale } from "@/lib/i18n";
import type { CommonContent } from "@/content/types";
import { getModeValue, isComingSoonMode } from "@/lib/site";
@@ -19,7 +20,16 @@ export default function SiteFooter({ locale, common }: SiteFooterProps) {
<p>{common.footerRights.replace("{year}", String(year))}</p>
<p>{commonVariant.footerBuiltWith}</p>
</div>
{!isComingSoon ? <p className="locale-badge">{locale.toUpperCase()}</p> : null}
{!isComingSoon ? (
<div className="flex flex-wrap items-center gap-4">
<nav aria-label={common.legal.label} className="flex flex-wrap gap-3 text-sm">
<Link href={`/${locale}/legal/privacy`} className="hover:text-foreground hover:underline">{common.legal.privacy}</Link>
<Link href={`/${locale}/legal/terms`} className="hover:text-foreground hover:underline">{common.legal.terms}</Link>
<Link href={`/${locale}/legal/impressum`} className="hover:text-foreground hover:underline">{common.legal.impressum}</Link>
</nav>
<p className="locale-badge">{locale.toUpperCase()}</p>
</div>
) : null}
</div>
</footer>
);
+3
View File
@@ -27,6 +27,9 @@ export default function SiteHeader({ locale, common }: SiteHeaderProps) {
{!isComingSoon ? (
<nav className="header-nav" aria-label={common.navLabel}>
<Link href={`/${locale}`}>{common.nav.home}</Link>
<Link href={`/${locale}/work`}>{common.nav.projects}</Link>
<Link href={`/${locale}/apps`}>{common.nav.apps}</Link>
<Link href={`/${locale}/melodies`}>{common.nav.melodies}</Link>
<Link href={`/${locale}/about`}>{common.nav.about}</Link>
<Link href={`/${locale}/contact`}>{common.nav.contact}</Link>
</nav>
+36
View File
@@ -0,0 +1,36 @@
"use client";
import type { ReactNode } from "react";
import type { FieldValues, SubmitHandler, UseFormReturn } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Form } from "@/components/ui/form";
import { cn } from "@/lib/utils";
type AdminFormProps<TFieldValues extends FieldValues> = {
form: UseFormReturn<TFieldValues>;
onSubmit: SubmitHandler<TFieldValues>;
children: ReactNode;
submitLabel: string;
submittingLabel?: string;
className?: string;
};
export default function AdminForm<TFieldValues extends FieldValues>({
form,
onSubmit,
children,
submitLabel,
submittingLabel = "Saving…",
className,
}: AdminFormProps<TFieldValues>) {
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className={cn("space-y-5", className)} noValidate>
{children}
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? submittingLabel : submitLabel}
</Button>
</form>
</Form>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
type AdminPageHeaderProps = {
eyebrow?: string;
title: string;
description?: string;
actions?: ReactNode;
};
export default function AdminPageHeader({ eyebrow = "Admin workspace", title, description, actions }: AdminPageHeaderProps) {
return (
<header className="mb-8 flex flex-col gap-4 border-b border-border pb-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.16em] text-brand-2">{eyebrow}</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">{title}</h1>
{description ? <p className="mt-2 max-w-2xl text-sm text-muted-foreground">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
</header>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button";
type AdminShellProps = {
children: ReactNode;
userEmail: string;
signOutAction: (formData: FormData) => Promise<void>;
};
const navigation = [
{ label: "Dashboard", href: "/admin" },
{ label: "Categories", href: "/admin/categories" },
{ label: "Projects", href: "/admin/projects" },
{ label: "Apps", href: "/admin/projects?type=APP" },
{ label: "Melodies", href: "/admin/melodies" },
{ label: "Analytics", href: "/admin/analytics" },
{ label: "Media", active: false },
];
export default function AdminShell({ children, userEmail, signOutAction }: AdminShellProps) {
const pathname = usePathname();
return (
<div className="min-h-screen bg-background text-foreground lg:grid lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside className="border-b border-border bg-card lg:sticky lg:top-0 lg:h-screen lg:border-b-0 lg:border-e">
<div className="flex h-full flex-col gap-8 p-5 sm:p-6 lg:p-5">
<div>
<Link href="/admin" className="text-lg font-bold tracking-tight">
Diyaa Admin
</Link>
<p className="mt-1 text-sm text-muted-foreground">Content workspace</p>
</div>
<nav aria-label="Admin navigation" className="grid gap-1">
{navigation.map((item) =>
item.href ? (
<Link
key={item.label}
href={item.href ?? "/admin"}
className={
pathname === item.href.split("?")[0] || (item.href.split("?")[0] !== "/admin" && pathname.startsWith(`${item.href.split("?")[0]}/`))
? "rounded-md bg-accent px-3 py-2 text-sm font-medium text-accent-foreground"
: "rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
}
>
{item.label}
</Link>
) : (
<span
key={item.label}
aria-disabled="true"
className="flex cursor-not-allowed items-center justify-between rounded-md px-3 py-2 text-sm text-muted-foreground opacity-60"
>
{item.label}
<span className="text-xs">Soon</span>
</span>
),
)}
</nav>
<div className="mt-auto border-t border-border pt-5">
<p className="truncate text-sm text-muted-foreground" title={userEmail}>
{userEmail}
</p>
<form action={signOutAction} className="mt-3">
<Button type="submit" variant="outline" className="w-full">
Sign out
</Button>
</form>
</div>
</div>
</aside>
<main className="min-w-0 p-4 sm:p-6 lg:p-8">{children}</main>
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AdminForm from "@/components/admin/admin-form";
import { createCategory, updateCategory } from "@/app/admin/(protected)/categories/actions";
import { categorySchema, type CategoryInput } from "@/lib/validations/category";
import { useRouter } from "next/navigation";
type CategoryFormProps = {
mode: "create" | "edit";
categoryId?: string;
defaultValues: CategoryInput;
};
const kindLabels: Record<CategoryInput["kind"], string> = {
PROJECT: "Projects",
MELODY: "Melodies",
};
export default function CategoryForm({ mode, categoryId, defaultValues }: CategoryFormProps) {
const router = useRouter();
const [serverError, setServerError] = useState<string | null>(null);
const form = useForm<CategoryInput>({
resolver: zodResolver(categorySchema),
defaultValues,
});
const onSubmit = async (values: CategoryInput) => {
setServerError(null);
const result =
mode === "create" ? await createCategory(values) : await updateCategory(categoryId ?? "", values);
if (result.error) {
setServerError(result.error);
return;
}
router.push("/admin/categories");
router.refresh();
};
return (
<AdminForm
form={form}
onSubmit={onSubmit}
submitLabel={mode === "create" ? "Create category" : "Save changes"}
submittingLabel="Saving…"
className="max-w-2xl"
>
<FormField
control={form.control}
name="kind"
render={({ field }) => (
<FormItem>
<FormLabel>Section</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose a section" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(kindLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="web-design" autoComplete="off" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-5 sm:grid-cols-2">
<FormField
control={form.control}
name="nameAr"
render={({ field }) => (
<FormItem>
<FormLabel>Arabic name</FormLabel>
<FormControl>
<Input dir="rtl" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nameEn"
render={({ field }) => (
<FormItem>
<FormLabel>English name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="order"
render={({ field }) => (
<FormItem>
<FormLabel>Display order</FormLabel>
<FormControl>
<Input
type="number"
min={0}
max={9999}
inputMode="numeric"
{...field}
onChange={(event) => field.onChange(event.target.valueAsNumber || 0)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{serverError ? <p className="text-sm font-medium text-destructive">{serverError}</p> : null}
</AdminForm>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
uploadedFileSchema,
UPLOAD_RULES,
type UploadKind,
type UploadedFileMetadata,
} from "@/lib/validations/upload";
type FileUploadProps = {
kind: UploadKind;
onUploaded: (files: UploadedFileMetadata[]) => void;
id?: string;
multiple?: boolean;
label?: string;
helperText?: string;
};
const defaultLabels: Record<UploadKind, string> = {
image: "Image",
audio: "Audio",
};
export default function FileUpload({
kind,
onUploaded,
id,
multiple = false,
label,
helperText,
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const rules = UPLOAD_RULES[kind];
const fieldId = id ?? `file-upload-${kind}`;
async function handleFiles(selectedFiles: File[]) {
setError(null);
setIsUploading(true);
try {
const uploaded: UploadedFileMetadata[] = [];
for (const file of selectedFiles) {
if (!rules.mimeTypes.includes(file.type) || file.size > rules.maxBytes) {
throw new Error(`Unsupported or oversized ${kind} file.`);
}
const body = new FormData();
body.set("kind", kind);
body.set("file", file);
const response = await fetch("/api/upload", { method: "POST", body });
const payload: unknown = await response.json();
if (!response.ok) {
const message =
typeof payload === "object" && payload !== null && "error" in payload
? String(payload.error)
: "The file could not be uploaded.";
throw new Error(message);
}
const parsed = uploadedFileSchema.safeParse(payload);
if (!parsed.success) {
throw new Error("The upload response was invalid.");
}
uploaded.push(parsed.data);
}
onUploaded(uploaded);
} catch (uploadError) {
setError(uploadError instanceof Error ? uploadError.message : "The file could not be uploaded.");
} finally {
setIsUploading(false);
if (inputRef.current) {
inputRef.current.value = "";
}
}
}
return (
<div className="space-y-2" dir="auto">
<Label htmlFor={fieldId}>{label ?? defaultLabels[kind]}</Label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
ref={inputRef}
id={fieldId}
type="file"
accept={rules.accept}
multiple={multiple}
disabled={isUploading}
onChange={(event) => {
void handleFiles(Array.from(event.target.files ?? []));
}}
/>
{isUploading ? (
<Button type="button" variant="secondary" disabled>
Uploading
</Button>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{helperText ?? `Maximum ${Math.round(rules.maxBytes / (1024 * 1024))} MB.`}
</p>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import AdminForm from "@/components/admin/admin-form";
import { loginSchema, type LoginInput } from "@/lib/validations/auth";
export default function LoginForm() {
const [serverError, setServerError] = useState<string | null>(null);
const form = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
});
const onSubmit = async (values: LoginInput) => {
setServerError(null);
const result = await signIn("credentials", {
email: values.email,
password: values.password,
redirect: false,
callbackUrl: "/admin",
});
if (!result || result.error) {
setServerError("The email or password is incorrect.");
return;
}
window.location.assign(result.url ?? "/admin");
};
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Admin sign in</CardTitle>
<CardDescription>Use your admin credentials to continue.</CardDescription>
</CardHeader>
<CardContent>
<AdminForm form={form} onSubmit={onSubmit} submitLabel="Sign in" submittingLabel="Signing in…" className="space-y-5">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" autoComplete="current-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{serverError ? <p className="text-sm font-medium text-destructive">{serverError}</p> : null}
</AdminForm>
</CardContent>
</Card>
);
}
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import AdminForm from "@/components/admin/admin-form";
import FileUpload from "@/components/admin/file-upload";
import { createMelody, updateMelody } from "@/app/admin/(protected)/melodies/actions";
import { melodySchema, type MelodyInput } from "@/lib/validations/melody";
type CategoryOption = { id: string; nameAr: string; nameEn: string };
type MelodyFormProps = {
mode: "create" | "edit";
melodyId?: string;
categories: CategoryOption[];
defaultValues: MelodyInput;
};
const statusLabels: Record<MelodyInput["status"], string> = {
DRAFT: "Draft",
PUBLISHED: "Published",
ARCHIVED: "Archived",
};
export default function MelodyForm({ mode, melodyId, categories, defaultValues }: MelodyFormProps) {
const router = useRouter();
const [serverError, setServerError] = useState<string | null>(null);
const form = useForm<MelodyInput>({ resolver: zodResolver(melodySchema), defaultValues });
const onSubmit = async (values: MelodyInput) => {
setServerError(null);
const result = mode === "create" ? await createMelody(values) : await updateMelody(melodyId ?? "", values);
if (result.error) {
setServerError(result.error);
return;
}
router.push("/admin/melodies");
router.refresh();
};
return (
<AdminForm
form={form}
onSubmit={onSubmit}
submitLabel={mode === "create" ? "Create melody" : "Save changes"}
submittingLabel="Saving…"
className="max-w-3xl"
>
<div className="grid gap-5 md:grid-cols-2">
<FormField control={form.control} name="status" render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl><SelectTrigger><SelectValue placeholder="Choose a status" /></SelectTrigger></FormControl>
<SelectContent>{Object.entries(statusLabels).map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="categoryId" render={({ field }) => (
<FormItem>
<FormLabel>Melody category</FormLabel>
<Select value={field.value || "__none"} onValueChange={(value) => field.onChange(value === "__none" ? "" : value)}>
<FormControl><SelectTrigger><SelectValue placeholder="Choose a category" /></SelectTrigger></FormControl>
<SelectContent>
<SelectItem value="__none">Choose a category</SelectItem>
{categories.map((category) => <SelectItem key={category.id} value={category.id}>{category.nameEn} · {category.nameAr}</SelectItem>)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
</div>
<FormField control={form.control} name="slug" render={({ field }) => (
<FormItem><FormLabel>Slug</FormLabel><FormControl><Input placeholder="melody-slug" {...field} /></FormControl><FormMessage /></FormItem>
)} />
<div className="grid gap-5 md:grid-cols-2">
<FormField control={form.control} name="titleEn" render={({ field }) => (
<FormItem><FormLabel>English title</FormLabel><FormControl><Input {...field} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="titleAr" render={({ field }) => (
<FormItem><FormLabel>Arabic title</FormLabel><FormControl><Input dir="rtl" {...field} /></FormControl><FormMessage /></FormItem>
)} />
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField control={form.control} name="descEn" render={({ field }) => (
<FormItem><FormLabel>English description</FormLabel><FormControl><textarea className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" {...field} value={field.value ?? ""} /></FormControl><FormMessage /></FormItem>
)} />
<FormField control={form.control} name="descAr" render={({ field }) => (
<FormItem><FormLabel>Arabic description</FormLabel><FormControl><textarea dir="rtl" className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" {...field} value={field.value ?? ""} /></FormControl><FormMessage /></FormItem>
)} />
</div>
<FormField control={form.control} name="audioFile" render={({ field }) => (
<FormItem>
<FormLabel>Audio file path</FormLabel>
<FormControl><Input placeholder="/api/uploads/audios/..." {...field} /></FormControl>
<FormMessage />
<FileUpload id="melody-audio-file" kind="audio" label="Upload audio file" onUploaded={(files) => field.onChange(files[0]?.url ?? field.value)} />
</FormItem>
)} />
<div className="grid gap-5 md:grid-cols-2">
<FormField control={form.control} name="coverImage" render={({ field }) => (
<FormItem>
<FormLabel>Cover image path</FormLabel>
<FormControl><Input placeholder="/api/uploads/images/..." {...field} value={field.value ?? ""} /></FormControl>
<FormMessage />
<FileUpload id="melody-cover-image" kind="image" label="Upload cover image" onUploaded={(files) => field.onChange(files[0]?.url ?? field.value ?? "")} />
</FormItem>
)} />
<FormField control={form.control} name="durationSec" render={({ field }) => (
<FormItem>
<FormLabel>Duration (seconds)</FormLabel>
<FormControl><Input type="number" min={0} max={86400} value={field.value ?? ""} onChange={(event) => field.onChange(event.target.value ? event.target.valueAsNumber : null)} /></FormControl>
<FormMessage />
</FormItem>
)} />
</div>
<div className="grid gap-3 md:grid-cols-2">
<FormField control={form.control} name="isDownloadable" render={({ field }) => (
<FormItem className="flex items-center gap-3 rounded-md border border-border p-3">
<FormControl><Input type="checkbox" checked={field.value} onChange={(event) => field.onChange(event.target.checked)} className="h-4 w-4" /></FormControl>
<FormLabel className="m-0">Allow audio download</FormLabel>
</FormItem>
)} />
<FormField control={form.control} name="isFeatured" render={({ field }) => (
<FormItem className="flex items-center gap-3 rounded-md border border-border p-3">
<FormControl><Input type="checkbox" checked={field.value} onChange={(event) => field.onChange(event.target.checked)} className="h-4 w-4" /></FormControl>
<FormLabel className="m-0">Feature this melody</FormLabel>
</FormItem>
)} />
</div>
<FormField control={form.control} name="sortOrder" render={({ field }) => (
<FormItem><FormLabel>Sort order</FormLabel><FormControl><Input type="number" min={0} max={9999} value={field.value} onChange={(event) => field.onChange(event.target.valueAsNumber || 0)} /></FormControl><FormMessage /></FormItem>
)} />
{serverError ? <p className="text-sm font-medium text-destructive">{serverError}</p> : null}
</AdminForm>
);
}
+452
View File
@@ -0,0 +1,452 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import AdminForm from "@/components/admin/admin-form";
import FileUpload from "@/components/admin/file-upload";
import { createProject, updateProject } from "@/app/admin/(protected)/projects/actions";
import { projectSchema, type ProjectInput } from "@/lib/validations/project";
import { useRouter } from "next/navigation";
type CategoryOption = {
id: string;
nameAr: string;
nameEn: string;
};
type ProjectFormProps = {
mode: "create" | "edit";
projectId?: string;
categories: CategoryOption[];
defaultValues: ProjectInput;
};
const typeLabels: Record<ProjectInput["type"], string> = {
PORTFOLIO: "Portfolio",
APP: "App",
WEBSITE: "Website",
DESIGN: "Design",
};
const statusLabels: Record<ProjectInput["status"], string> = {
DRAFT: "Draft",
PUBLISHED: "Published",
ARCHIVED: "Archived",
};
function parseList(value: string) {
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
export default function ProjectForm({ mode, projectId, categories, defaultValues }: ProjectFormProps) {
const router = useRouter();
const [serverError, setServerError] = useState<string | null>(null);
const form = useForm<ProjectInput>({
resolver: zodResolver(projectSchema),
defaultValues,
});
const projectType = form.watch("type");
const onSubmit = async (values: ProjectInput) => {
setServerError(null);
const result = mode === "create" ? await createProject(values) : await updateProject(projectId ?? "", values);
if (result.error) {
setServerError(result.error);
return;
}
router.push("/admin/projects");
router.refresh();
};
return (
<AdminForm
form={form}
onSubmit={onSubmit}
submitLabel={mode === "create" ? "Create project" : "Save changes"}
submittingLabel="Saving…"
className="max-w-3xl"
>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Project type</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose a type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(typeLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(statusLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input placeholder="project-slug" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="titleEn"
render={({ field }) => (
<FormItem>
<FormLabel>English title</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="titleAr"
render={({ field }) => (
<FormItem>
<FormLabel>Arabic title</FormLabel>
<FormControl>
<Input dir="rtl" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="summaryEn"
render={({ field }) => (
<FormItem>
<FormLabel>English summary</FormLabel>
<FormControl>
<Input {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="summaryAr"
render={({ field }) => (
<FormItem>
<FormLabel>Arabic summary</FormLabel>
<FormControl>
<Input dir="rtl" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="descEn"
render={({ field }) => (
<FormItem>
<FormLabel>English description</FormLabel>
<FormControl>
<textarea className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="descAr"
render={({ field }) => (
<FormItem>
<FormLabel>Arabic description</FormLabel>
<FormControl>
<textarea dir="rtl" className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Project category</FormLabel>
<Select value={field.value || "__none"} onValueChange={(value) => field.onChange(value === "__none" ? "" : value)}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Choose a category" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="__none">No category</SelectItem>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.nameEn} · {category.nameAr}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="coverImage"
render={({ field }) => (
<FormItem>
<FormLabel>Cover image path</FormLabel>
<FormControl>
<Input placeholder="/api/uploads/images/..." {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
<FileUpload
id="project-cover-image"
kind="image"
label="Upload cover image"
onUploaded={(files) => field.onChange(files[0]?.url ?? field.value ?? "")}
/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="images"
render={({ field }) => (
<FormItem>
<FormLabel>Gallery image paths</FormLabel>
<FormControl>
<textarea
className="min-h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="One uploaded path per line"
value={field.value.join("\n")}
onChange={(event) => field.onChange(parseList(event.target.value))}
/>
</FormControl>
<FormMessage />
<FileUpload
id="project-gallery-images"
kind="image"
label="Upload gallery images"
multiple
onUploaded={(files) => field.onChange([...field.value, ...files.map((file) => file.url)])}
/>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="technologies"
render={({ field }) => (
<FormItem>
<FormLabel>Technologies</FormLabel>
<FormControl>
<textarea
className="min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
placeholder="Next.js, TypeScript, PostgreSQL"
value={field.value.join("\n")}
onChange={(event) => field.onChange(parseList(event.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="externalUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Live / external URL</FormLabel>
<FormControl>
<Input type="url" placeholder="https://example.com" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Repository URL</FormLabel>
<FormControl>
<Input type="url" placeholder="https://github.com/..." {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="sortOrder"
render={({ field }) => (
<FormItem>
<FormLabel>Sort order</FormLabel>
<FormControl>
<Input type="number" min={0} max={9999} value={field.value} onChange={(event) => field.onChange(event.target.valueAsNumber || 0)} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isFeatured"
render={({ field }) => (
<FormItem className="flex items-center gap-3 rounded-md border border-border p-3 md:mt-7">
<FormControl>
<Input type="checkbox" checked={field.value} onChange={(event) => field.onChange(event.target.checked)} className="h-4 w-4" />
</FormControl>
<FormLabel className="m-0">Feature this project</FormLabel>
</FormItem>
)}
/>
</div>
{projectType === "APP" ? (
<section className="space-y-5 rounded-xl border border-border bg-card p-5">
<div>
<h2 className="text-lg font-semibold">iOS app details</h2>
<p className="mt-1 text-sm text-muted-foreground">Add the store, release, and support details for this application.</p>
</div>
<div className="grid gap-5 md:grid-cols-2">
<FormField
control={form.control}
name="platform"
render={({ field }) => (
<FormItem>
<FormLabel>Platform</FormLabel>
<FormControl>
<Input placeholder="iOS / Swift" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appVersion"
render={({ field }) => (
<FormItem>
<FormLabel>App version</FormLabel>
<FormControl>
<Input placeholder="1.0.0" {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-5 md:grid-cols-2">
{([
["appStoreUrl", "App Store URL"],
["testflightUrl", "TestFlight URL"],
["supportUrl", "Support URL"],
["appPrivacyUrl", "App privacy URL"],
] as const).map(([name, label]) => (
<FormField
key={name}
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<Input type="url" placeholder="https://..." {...field} value={field.value ?? ""} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</section>
) : null}
{serverError ? <p className="text-sm font-medium text-destructive">{serverError}</p> : null}
</AdminForm>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Body, Container, Head, Heading, Html, Link, Preview, Section, Text } from "@react-email/components";
type ContactMessageEmailProps = {
name: string;
email: string;
message: string;
siteUrl: string;
};
export default function ContactMessageEmail({ name, email, message, siteUrl }: ContactMessageEmailProps) {
return (
<Html>
<Head />
<Preview>New contact message from {name}</Preview>
<Body style={{ backgroundColor: "#f4f4f5", fontFamily: "Arial, sans-serif", padding: "32px 0" }}>
<Container style={{ backgroundColor: "#ffffff", margin: "0 auto", maxWidth: "560px", padding: "32px" }}>
<Heading style={{ color: "#18181b", fontSize: "24px" }}>New contact message</Heading>
<Text style={{ color: "#52525b", fontSize: "15px" }}>Someone sent a new message through your website.</Text>
<Section style={{ borderTop: "1px solid #e4e4e7", marginTop: "24px", paddingTop: "20px" }}>
<Text style={{ color: "#18181b", fontSize: "15px", margin: "8px 0" }}><strong>Name:</strong> {name}</Text>
<Text style={{ color: "#18181b", fontSize: "15px", margin: "8px 0" }}><strong>Email:</strong> <Link href={`mailto:${email}`}>{email}</Link></Text>
<Text style={{ color: "#18181b", fontSize: "15px", lineHeight: "1.6", whiteSpace: "pre-wrap" }}><strong>Message:</strong><br />{message}</Text>
</Section>
<Text style={{ color: "#71717a", fontSize: "12px", marginTop: "28px" }}><Link href={siteUrl}>Open website</Link></Text>
</Container>
</Body>
</Html>
);
}
export function ContactConfirmationEmail({ name, siteUrl }: Pick<ContactMessageEmailProps, "name" | "siteUrl">) {
return (
<Html>
<Head />
<Preview>Thanks for contacting Diyaa</Preview>
<Body style={{ backgroundColor: "#f4f4f5", fontFamily: "Arial, sans-serif", padding: "32px 0" }}>
<Container style={{ backgroundColor: "#ffffff", margin: "0 auto", maxWidth: "560px", padding: "32px" }}>
<Heading style={{ color: "#18181b", fontSize: "24px" }}>Thanks for getting in touch</Heading>
<Text style={{ color: "#52525b", fontSize: "15px", lineHeight: "1.6" }}>Hi {name}, your message was received. I will get back to you as soon as possible.</Text>
<Text style={{ color: "#71717a", fontSize: "12px", marginTop: "28px" }}><Link href={siteUrl}>Visit the website</Link></Text>
</Container>
</Body>
</Html>
);
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { useEffect } from "react";
import { trackClientEvent } from "@/lib/analytics-client";
type AnalyticsTrackerProps = {
event: "PAGE_VIEW" | "PROJECT_OPEN";
entityId?: string;
};
export default function AnalyticsTracker({ event, entityId }: AnalyticsTrackerProps) {
useEffect(() => {
trackClientEvent({ type: event, entityId });
}, [event, entityId]);
return null;
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { trackClientEvent } from "@/lib/analytics-client";
type AudioPlayerProps = {
src: string;
title: string;
locale: "ar" | "en";
downloadable?: boolean;
analytics?: { entityId: string };
className?: string;
};
function formatTime(value: number) {
if (!Number.isFinite(value) || value < 0) return "0:00";
const minutes = Math.floor(value / 60);
const seconds = Math.floor(value % 60).toString().padStart(2, "0");
return `${minutes}:${seconds}`;
}
export default function AudioPlayer({ src, title, locale, downloadable = false, analytics, className }: AudioPlayerProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const hasTrackedPlay = useRef(false);
const labels = locale === "ar"
? { play: "تشغيل", pause: "إيقاف مؤقت", back: "تأخير 10 ثوانٍ", forward: "تقديم 10 ثوانٍ", seek: "موضع التشغيل", volume: "مستوى الصوت", download: "تحميل الصوت" }
: { play: "Play", pause: "Pause", back: "Back 10 seconds", forward: "Forward 10 seconds", seek: "Playback position", volume: "Volume", download: "Download audio" };
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onLoadedMetadata = () => setDuration(audio.duration);
const onTimeUpdate = () => setCurrentTime(audio.currentTime);
const onEnded = () => setIsPlaying(false);
audio.addEventListener("loadedmetadata", onLoadedMetadata);
audio.addEventListener("timeupdate", onTimeUpdate);
audio.addEventListener("ended", onEnded);
audio.volume = volume;
return () => {
audio.removeEventListener("loadedmetadata", onLoadedMetadata);
audio.removeEventListener("timeupdate", onTimeUpdate);
audio.removeEventListener("ended", onEnded);
};
}, [volume]);
const togglePlayback = async () => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
await audio.play();
setIsPlaying(true);
if (analytics && !hasTrackedPlay.current) {
hasTrackedPlay.current = true;
trackClientEvent({ type: "MELODY_PLAY", entityId: analytics.entityId });
}
} else {
audio.pause();
setIsPlaying(false);
}
};
const seekBy = (seconds: number) => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = Math.max(0, Math.min(audio.duration || 0, audio.currentTime + seconds));
setCurrentTime(audio.currentTime);
};
const updateTime = (value: number) => {
const audio = audioRef.current;
if (!audio) return;
audio.currentTime = value;
setCurrentTime(value);
};
const updateVolume = (value: number) => {
setVolume(value);
if (audioRef.current) audioRef.current.volume = value;
};
return (
<div className={cn("rounded-xl border border-border bg-card p-4", className)}>
<audio ref={audioRef} src={src} preload="metadata" />
<div className="flex items-center gap-2">
<button type="button" onClick={togglePlayback} aria-label={isPlaying ? labels.pause : labels.play} className="inline-flex h-10 min-w-10 items-center justify-center rounded-full bg-primary px-3 text-sm font-medium text-primary-foreground hover:opacity-90">
{isPlaying ? "Ⅱ" : "▶"}
</button>
<button type="button" onClick={() => seekBy(-10)} aria-label={labels.back} className="rounded-md border border-border px-2 py-2 text-xs hover:bg-accent">10</button>
<button type="button" onClick={() => seekBy(10)} aria-label={labels.forward} className="rounded-md border border-border px-2 py-2 text-xs hover:bg-accent">+10</button>
<span className="ms-auto text-xs tabular-nums text-muted-foreground">{formatTime(currentTime)} / {formatTime(duration)}</span>
</div>
<input type="range" min={0} max={duration || 0} step="any" value={Math.min(currentTime, duration || 0)} onChange={(event) => updateTime(event.target.valueAsNumber)} aria-label={labels.seek} className="mt-4 w-full accent-primary" />
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
<span aria-hidden="true">🔊</span>
<input type="range" min={0} max={1} step="0.05" value={volume} onChange={(event) => updateVolume(event.target.valueAsNumber)} aria-label={labels.volume} className="w-28 accent-primary" />
{downloadable ? <a href={src} download className="ms-auto font-medium text-brand-2 hover:underline">{labels.download}</a> : null}
</div>
<p className="sr-only">{title}</p>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { submitContactMessage } from "@/app/[locale]/contact/actions";
type ContactFormProps = {
labels: {
name: string;
email: string;
message: string;
submit: string;
sending: string;
success: string;
savedWarning: string;
invalid: string;
rateLimit: string;
};
};
export default function ContactForm({ labels }: ContactFormProps) {
const [status, setStatus] = useState<{ type: "success" | "warning" | "error"; message: string } | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus(null);
setIsSubmitting(true);
const form = event.currentTarget;
const result = await submitContactMessage(Object.fromEntries(new FormData(form).entries()));
setIsSubmitting(false);
if (result.error) {
setStatus({ type: "error", message: result.error === "rate-limit" ? labels.rateLimit : labels.invalid });
return;
}
form.reset();
setStatus({ type: result.warning ? "warning" : "success", message: result.warning ? labels.savedWarning : labels.success });
}
return (
<form onSubmit={handleSubmit} className="card contact-form space-y-4" noValidate>
<div className="grid gap-4 sm:grid-cols-2">
<label className="space-y-2"><span>{labels.name}</span><input name="name" required maxLength={100} autoComplete="name" /></label>
<label className="space-y-2"><span>{labels.email}</span><input name="email" type="email" required maxLength={254} autoComplete="email" /></label>
</div>
<label className="space-y-2"><span>{labels.message}</span><textarea name="message" required minLength={10} maxLength={5000} rows={6} /></label>
<label aria-hidden="true" className="absolute -left-[9999px] h-px w-px overflow-hidden"><span>Website</span><input name="website" tabIndex={-1} autoComplete="off" /></label>
<button type="submit" disabled={isSubmitting}>{isSubmitting ? labels.sending : labels.submit}</button>
{status ? <p role={status.type === "error" ? "alert" : "status"} className={status.type === "error" ? "text-sm text-destructive" : "text-sm text-muted-foreground"}>{status.message}</p> : null}
</form>
);
}
+22
View File
@@ -0,0 +1,22 @@
import type { LegalDocument } from "@/lib/legal-content";
export default function LegalDocument({ document }: { document: LegalDocument }) {
return (
<article className="mx-auto w-full max-w-4xl space-y-10 px-4 py-10 sm:px-6 lg:px-8">
<header className="space-y-4 border-b border-border pb-8">
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">{document.title}</h1>
<p className="max-w-3xl text-lg leading-8 text-muted-foreground">{document.intro}</p>
<p className="text-sm text-muted-foreground">{document.updatedLabel}</p>
</header>
<div className="space-y-8">
{document.sections.map((section) => (
<section key={section.heading} className="space-y-3">
<h2 className="text-2xl font-semibold tracking-tight">{section.heading}</h2>
{section.paragraphs?.map((paragraph) => <p key={paragraph} className="whitespace-pre-line leading-8 text-foreground">{paragraph}</p>)}
{section.list ? <ul className="list-inside list-disc space-y-2 leading-8 text-foreground">{section.list.map((item) => <li key={item}>{item}</li>)}</ul> : null}
</section>
))}
</div>
</article>
);
}
+36
View File
@@ -0,0 +1,36 @@
import Image from "next/image";
import Link from "next/link";
import type { Locale } from "@/lib/i18n";
import { getMelodyPath } from "@/lib/melody-content";
import AudioPlayer from "@/components/public/audio-player";
type MelodyCardProps = {
locale: Locale;
melody: {
slug: string;
id: string;
titleAr: string;
titleEn: string;
descAr: string | null;
descEn: string | null;
audioFile: string;
coverImage: string | null;
isDownloadable: boolean;
category: { nameAr: string; nameEn: string };
};
};
export default function MelodyCard({ locale, melody }: MelodyCardProps) {
const title = locale === "ar" ? melody.titleAr : melody.titleEn;
const description = locale === "ar" ? melody.descAr : melody.descEn;
const category = locale === "ar" ? melody.category.nameAr : melody.category.nameEn;
return (
<article className="overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
{melody.coverImage ? <Link href={getMelodyPath(locale, melody.slug)} className="relative block aspect-[16/9] bg-muted"><Image src={melody.coverImage} alt={title} fill unoptimized sizes="(max-width: 768px) 100vw, 50vw" className="object-cover" /></Link> : null}
<div className="space-y-4 p-5">
<div><p className="text-xs font-semibold uppercase tracking-[0.16em] text-brand-2">{category}</p><h2 className="mt-2 text-xl font-semibold"><Link href={getMelodyPath(locale, melody.slug)} className="hover:underline">{title}</Link></h2>{description ? <p className="mt-2 line-clamp-2 text-sm leading-6 text-muted-foreground">{description}</p> : null}</div>
<AudioPlayer src={melody.audioFile} title={title} locale={locale} downloadable={melody.isDownloadable} analytics={{ entityId: melody.id }} />
</div>
</article>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Image from "next/image";
import Link from "next/link";
import type { Locale } from "@/lib/i18n";
import { getMelodyDirectoryCopy, getMelodyPath } from "@/lib/melody-content";
import AudioPlayer from "@/components/public/audio-player";
export default function MelodyDetail({ locale, melody }: { locale: Locale; melody: { id: string; slug: string; titleAr: string; titleEn: string; descAr: string | null; descEn: string | null; audioFile: string; coverImage: string | null; isDownloadable: boolean; category: { nameAr: string; nameEn: string } } }) {
const title = locale === "ar" ? melody.titleAr : melody.titleEn;
const description = locale === "ar" ? melody.descAr : melody.descEn;
const category = locale === "ar" ? melody.category.nameAr : melody.category.nameEn;
const copy = getMelodyDirectoryCopy(locale);
return (
<article className="mx-auto w-full max-w-5xl space-y-10 px-4 py-10 sm:px-6 lg:px-8">
<Link href={getMelodyPath(locale)} className="text-sm font-medium text-brand-2 hover:underline"> {copy.back}</Link>
<header className="max-w-3xl space-y-4"><p className="text-sm font-semibold uppercase tracking-[0.18em] text-brand-2">{category}</p><h1 className="text-4xl font-semibold tracking-tight sm:text-6xl">{title}</h1>{description ? <p className="text-xl leading-8 text-muted-foreground">{description}</p> : null}</header>
{melody.coverImage ? <div className="relative aspect-[16/7] overflow-hidden rounded-2xl border border-border bg-muted"><Image src={melody.coverImage} alt={title} fill unoptimized sizes="100vw" className="object-cover" priority /></div> : null}
<div className="max-w-2xl"><AudioPlayer src={melody.audioFile} title={title} locale={locale} downloadable={melody.isDownloadable} analytics={{ entityId: melody.id }} /></div>
</article>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
import type { Locale } from "@/lib/i18n";
import { getMelodyDirectoryCopy, getMelodyPath } from "@/lib/melody-content";
import { getMelodyCategories, getPublishedMelodies } from "@/lib/melody-queries";
import MelodyCard from "@/components/public/melody-card";
export default async function MelodyDirectory({ locale, categorySlug }: { locale: Locale; categorySlug?: string }) {
const [melodies, categories] = await Promise.all([getPublishedMelodies(categorySlug), getMelodyCategories()]);
const copy = getMelodyDirectoryCopy(locale);
const allHref = getMelodyPath(locale);
return (
<section className="mx-auto w-full max-w-6xl space-y-8 px-4 py-10 sm:px-6 lg:px-8">
<header className="max-w-3xl space-y-3"><p className="text-sm font-semibold uppercase tracking-[0.18em] text-brand-2">{copy.eyebrow}</p><h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">{copy.title}</h1><p className="text-lg leading-8 text-muted-foreground">{copy.description}</p></header>
{categories.length > 0 ? <nav aria-label={copy.filterLabel} className="flex flex-wrap gap-2"><Link href={allHref} className={!categorySlug ? "rounded-full bg-primary px-4 py-2 text-sm text-primary-foreground" : "rounded-full border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"}>{copy.all}</Link>{categories.map((category) => <Link key={category.id} href={`${allHref}?category=${category.slug}`} className={categorySlug === category.slug ? "rounded-full bg-primary px-4 py-2 text-sm text-primary-foreground" : "rounded-full border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"}>{locale === "ar" ? category.nameAr : category.nameEn}</Link>)}</nav> : null}
{melodies.length === 0 ? <div className="rounded-xl border border-dashed border-border bg-card p-10 text-center text-muted-foreground">{copy.empty}</div> : <div className="grid gap-6 md:grid-cols-2">{melodies.map((melody) => <MelodyCard key={melody.id} locale={locale} melody={melody} />)}</div>}
</section>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Image from "next/image";
import Link from "next/link";
import type { ProjectType } from "@prisma/client";
import type { Locale } from "@/lib/i18n";
import { getProjectPath } from "@/lib/project-content";
type ProjectCardProps = {
locale: Locale;
type: ProjectType;
project: {
slug: string;
titleAr: string;
titleEn: string;
summaryAr: string | null;
summaryEn: string | null;
coverImage: string | null;
category: { nameAr: string; nameEn: string } | null;
};
};
export default function ProjectCard({ locale, type, project }: ProjectCardProps) {
const title = locale === "ar" ? project.titleAr : project.titleEn;
const summary = locale === "ar" ? project.summaryAr : project.summaryEn;
const category = project.category ? (locale === "ar" ? project.category.nameAr : project.category.nameEn) : null;
return (
<article className="group overflow-hidden rounded-xl border border-border bg-card shadow-sm transition-transform hover:-translate-y-1">
<Link href={getProjectPath(locale, type, project.slug)} className="block">
<div className="relative aspect-[16/10] overflow-hidden bg-muted">
{project.coverImage ? (
<Image
src={project.coverImage}
alt={title}
fill
unoptimized
sizes="(max-width: 768px) 100vw, 33vw"
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">{title}</div>
)}
</div>
<div className="space-y-2 p-5">
{category ? <p className="text-xs font-semibold uppercase tracking-[0.14em] text-brand-2">{category}</p> : null}
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
{summary ? <p className="text-sm leading-6 text-muted-foreground">{summary}</p> : null}
</div>
</Link>
</article>
);
}
+136
View File
@@ -0,0 +1,136 @@
import Image from "next/image";
import Link from "next/link";
import type { ProjectType } from "@prisma/client";
import type { Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy, getProjectPath } from "@/lib/project-content";
import AnalyticsTracker from "@/components/public/analytics-tracker";
import TrackedLink from "@/components/public/tracked-link";
type ProjectDetailProps = {
locale: Locale;
type: ProjectType;
project: {
id: string;
slug: string;
titleAr: string;
titleEn: string;
descAr: string | null;
descEn: string | null;
summaryAr: string | null;
summaryEn: string | null;
coverImage: string | null;
images: string[];
technologies: string[];
externalUrl: string | null;
repoUrl: string | null;
platform: string | null;
appStoreUrl: string | null;
testflightUrl: string | null;
appVersion: string | null;
supportUrl: string | null;
appPrivacyUrl: string | null;
category: { nameAr: string; nameEn: string } | null;
};
};
export default function ProjectDetail({ locale, type, project }: ProjectDetailProps) {
const title = locale === "ar" ? project.titleAr : project.titleEn;
const summary = locale === "ar" ? project.summaryAr : project.summaryEn;
const description = locale === "ar" ? project.descAr : project.descEn;
const category = project.category ? (locale === "ar" ? project.category.nameAr : project.category.nameEn) : null;
const copy = getProjectDirectoryCopy(locale, type);
const backLabel = locale === "ar" ? `العودة إلى ${copy.eyebrow}` : `Back to ${copy.eyebrow}`;
const isApp = type === "APP";
return (
<article className="mx-auto w-full max-w-6xl space-y-10 px-4 py-10 sm:px-6 lg:px-8">
<AnalyticsTracker event="PROJECT_OPEN" entityId={project.id} />
<Link href={getProjectPath(locale, type)} className="text-sm font-medium text-brand-2 hover:underline">
{backLabel}
</Link>
<header className="max-w-3xl space-y-4">
{category ? <p className="text-sm font-semibold uppercase tracking-[0.18em] text-brand-2">{category}</p> : null}
<h1 className="text-4xl font-semibold tracking-tight sm:text-6xl">{title}</h1>
{summary ? <p className="text-xl leading-8 text-muted-foreground">{summary}</p> : null}
</header>
{project.coverImage ? (
<div className="relative aspect-[16/8] overflow-hidden rounded-2xl border border-border bg-muted">
<Image src={project.coverImage} alt={title} fill unoptimized sizes="100vw" className="object-cover" priority />
</div>
) : null}
<div className="grid gap-10 lg:grid-cols-[minmax(0,1fr)_18rem]">
<div className="space-y-8">
{description ? <p className="whitespace-pre-line text-lg leading-8 text-foreground">{description}</p> : null}
{project.images.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{project.images.map((image, index) => (
<div key={image} className="relative aspect-[4/3] overflow-hidden rounded-xl border border-border bg-muted">
<Image src={image} alt={`${title} ${index + 1}`} fill unoptimized sizes="(max-width: 640px) 100vw, 50vw" className="object-cover" />
</div>
))}
</div>
) : null}
</div>
<aside className="space-y-6 rounded-xl border border-border bg-card p-5">
{project.technologies.length > 0 ? (
<div>
<h2 className="text-sm font-semibold">{locale === "ar" ? "التقنيات" : "Technologies"}</h2>
<div className="mt-3 flex flex-wrap gap-2">
{project.technologies.map((technology) => (
<span key={technology} className="rounded-full border border-border px-3 py-1 text-xs text-muted-foreground">
{technology}
</span>
))}
</div>
</div>
) : null}
{isApp ? (
<div className="space-y-3 border-t border-border pt-5 text-sm">
<h2 className="font-semibold">{locale === "ar" ? "تفاصيل التطبيق" : "App details"}</h2>
{project.platform ? <p className="text-muted-foreground">{project.platform}</p> : null}
{project.appVersion ? <p className="text-muted-foreground">{locale === "ar" ? "الإصدار" : "Version"}: {project.appVersion}</p> : null}
</div>
) : null}
<div className="flex flex-col gap-3 text-sm">
{project.externalUrl ? (
<TrackedLink href={project.externalUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "فتح الرابط" : "Open live link"}
</TrackedLink>
) : null}
{project.repoUrl ? (
<TrackedLink href={project.repoUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "مستودع GitHub" : "Open repository"}
</TrackedLink>
) : null}
{project.appStoreUrl ? (
<TrackedLink href={project.appStoreUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "فتح App Store" : "Open App Store"}
</TrackedLink>
) : null}
{project.testflightUrl ? (
<TrackedLink href={project.testflightUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "فتح TestFlight" : "Open TestFlight"}
</TrackedLink>
) : null}
{project.supportUrl ? (
<TrackedLink href={project.supportUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "رابط الدعم" : "Support link"}
</TrackedLink>
) : null}
{project.appPrivacyUrl ? (
<TrackedLink href={project.appPrivacyUrl} target="_blank" rel="noreferrer" className="font-medium text-brand-2 hover:underline" entityId={project.id}>
{locale === "ar" ? "سياسة الخصوصية" : "Privacy policy"}
</TrackedLink>
) : null}
</div>
</aside>
</div>
</article>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import type { ProjectType } from "@prisma/client";
import type { Locale } from "@/lib/i18n";
import { getProjectDirectoryCopy, getProjectPath } from "@/lib/project-content";
import { getProjectCategories, getPublishedProjects } from "@/lib/project-queries";
import ProjectCard from "@/components/public/project-card";
type ProjectDirectoryProps = {
locale: Locale;
type: ProjectType;
categorySlug?: string;
};
export default async function ProjectDirectory({ locale, type, categorySlug }: ProjectDirectoryProps) {
const [projects, categories] = await Promise.all([getPublishedProjects(type, categorySlug), getProjectCategories()]);
const copy = getProjectDirectoryCopy(locale, type);
const allHref = getProjectPath(locale, type);
return (
<section className="mx-auto w-full max-w-6xl space-y-8 px-4 py-10 sm:px-6 lg:px-8">
<header className="max-w-3xl space-y-3">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-brand-2">{copy.eyebrow}</p>
<h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">{copy.title}</h1>
<p className="text-lg leading-8 text-muted-foreground">{copy.description}</p>
</header>
{categories.length > 0 ? (
<nav aria-label={locale === "ar" ? "فلترة التصنيفات" : "Filter by category"} className="flex flex-wrap gap-2">
<Link
href={allHref}
className={!categorySlug ? "rounded-full bg-primary px-4 py-2 text-sm text-primary-foreground" : "rounded-full border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"}
>
{locale === "ar" ? "الكل" : "All"}
</Link>
{categories.map((category) => (
<Link
key={category.id}
href={`${allHref}?category=${category.slug}`}
className={categorySlug === category.slug ? "rounded-full bg-primary px-4 py-2 text-sm text-primary-foreground" : "rounded-full border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent"}
>
{locale === "ar" ? category.nameAr : category.nameEn}
</Link>
))}
</nav>
) : null}
{projects.length === 0 ? (
<div className="rounded-xl border border-dashed border-border bg-card p-10 text-center text-muted-foreground">{copy.empty}</div>
) : (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{projects.map((project) => (
<ProjectCard key={project.id} locale={locale} type={type} project={project} />
))}
</div>
)}
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
"use client";
import type { AnchorHTMLAttributes, ReactNode } from "react";
import { trackClientEvent } from "@/lib/analytics-client";
type TrackedLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
entityId: string;
children: ReactNode;
};
export default function TrackedLink({ entityId, onClick, children, ...props }: TrackedLinkProps) {
return <a {...props} onClick={(event) => { trackClientEvent({ type: "PROJECT_LINK_CLICK", entityId }); onClick?.(event); }}>{children}</a>;
}
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:opacity-90",
destructive: "bg-destructive text-destructive-foreground hover:opacity-90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:opacity-90",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-card border border-border bg-card text-card-foreground shadow-sm", className)} {...props} />
),
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />,
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+89
View File
@@ -0,0 +1,89 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed start-1/2 top-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-card border border-border bg-background p-6 text-foreground shadow-theme duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-start", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+134
View File
@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ ...props }: ControllerProps<TFieldValues, TName>) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext.name) {
throw new Error("useFormField must be used within <FormField>");
}
if (!itemContext.id) {
throw new Error("useFormField must be used within <FormItem>");
}
return {
id: itemContext.id,
name: fieldContext.name,
formItemId: `${itemContext.id}-form-item`,
formDescriptionId: `${itemContext.id}-form-item-description`,
formMessageId: `${itemContext.id}-form-item-message`,
...fieldState,
};
};
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />;
},
);
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error.message ?? "") : children;
if (!body) {
return null;
}
return (
<p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = "FormMessage";
export { useFormField, Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage };
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+128
View File
@@ -0,0 +1,128 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton ref={ref} className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton ref={ref} className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]")}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn("py-1.5 ps-8 pe-2 text-sm font-semibold", className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute start-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};