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