Add Oudelaa dashboard API integration
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
هذا الالتزام موجود في:
175
oudelaa_dashboard/components/auth/auth-guard.tsx
Normal file
175
oudelaa_dashboard/components/auth/auth-guard.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
import { SuperAdminSessionProvider } from "@/components/auth/session-context";
|
||||
import { getSuperAdminSession } from "@/lib/api/superadmin";
|
||||
import { refreshSuperAdmin } from "@/lib/auth/client";
|
||||
import type { SuperAdminSessionResponse } from "@/types/api";
|
||||
|
||||
const RETRY_DELAY_MS = 5000;
|
||||
|
||||
function isRecoverableSessionError(error: unknown) {
|
||||
const message = String(error).toLowerCase();
|
||||
return [
|
||||
"502",
|
||||
"503",
|
||||
"504",
|
||||
"fetch failed",
|
||||
"failed to fetch",
|
||||
"econnrefused",
|
||||
"timeout",
|
||||
"network",
|
||||
"upstream",
|
||||
].some((token) => message.includes(token));
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [sessionError, setSessionError] = useState<string | null>(null);
|
||||
const [session, setSession] = useState<SuperAdminSessionResponse | null>(null);
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
const [retryCountdown, setRetryCountdown] = useState<number | null>(null);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let retryTimeout: number | null = null;
|
||||
let countdownInterval: number | null = null;
|
||||
|
||||
const clearRetryTimers = () => {
|
||||
if (retryTimeout) {
|
||||
window.clearTimeout(retryTimeout);
|
||||
retryTimeout = null;
|
||||
}
|
||||
|
||||
if (countdownInterval) {
|
||||
window.clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRetry = () => {
|
||||
clearRetryTimers();
|
||||
setRetryCountdown(RETRY_DELAY_MS / 1000);
|
||||
|
||||
countdownInterval = window.setInterval(() => {
|
||||
setRetryCountdown((value) => (value && value > 1 ? value - 1 : 1));
|
||||
}, 1000);
|
||||
|
||||
retryTimeout = window.setTimeout(() => {
|
||||
clearRetryTimers();
|
||||
setRetryCountdown(null);
|
||||
if (active) {
|
||||
setRetryNonce((value) => value + 1);
|
||||
}
|
||||
}, RETRY_DELAY_MS);
|
||||
};
|
||||
|
||||
const ensureSession = async () => {
|
||||
try {
|
||||
const nextSession = await getSuperAdminSession();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRetryTimers();
|
||||
setSession(nextSession);
|
||||
setSessionError(null);
|
||||
setRetryCountdown(null);
|
||||
setReady(true);
|
||||
} catch (initialError) {
|
||||
try {
|
||||
const refreshed = await refreshSuperAdmin();
|
||||
if (!refreshed) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSession = await getSuperAdminSession();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRetryTimers();
|
||||
setSession(nextSession);
|
||||
setSessionError(null);
|
||||
setRetryCountdown(null);
|
||||
setReady(true);
|
||||
} catch (error) {
|
||||
if (String(initialError).includes("401") || String(error).includes("401")) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSession(null);
|
||||
setSessionError(String(error));
|
||||
setReady(true);
|
||||
|
||||
if (isRecoverableSessionError(initialError) || isRecoverableSessionError(error)) {
|
||||
scheduleRetry();
|
||||
} else {
|
||||
clearRetryTimers();
|
||||
setRetryCountdown(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void ensureSession();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
clearRetryTimers();
|
||||
};
|
||||
}, [router, pathname, retryNonce]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="frame-panel mx-auto mt-10 w-full max-w-2xl p-6 text-sm text-muted-foreground">
|
||||
Checking the current SuperAdmin session...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessionError && !session) {
|
||||
return (
|
||||
<SuperAdminSessionProvider value={{ session: null, permissions: [] }}>
|
||||
<div className="frame-panel mx-auto mt-10 w-full max-w-2xl space-y-4 p-6 text-sm text-muted-foreground">
|
||||
<div>
|
||||
The dashboard could not verify the current session, so permissions were not loaded.
|
||||
Reload the page after the backend connection is available again.
|
||||
</div>
|
||||
<div className="break-words text-xs text-red-300">{sessionError}</div>
|
||||
{retryCountdown !== null ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Retrying automatically in {retryCountdown}s...
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-border px-4 py-2 text-foreground transition hover:bg-secondary"
|
||||
onClick={() => {
|
||||
setRetryCountdown(null);
|
||||
setRetryNonce((value) => value + 1);
|
||||
}}
|
||||
>
|
||||
Retry now
|
||||
</button>
|
||||
</div>
|
||||
</SuperAdminSessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SuperAdminSessionProvider value={{ session, permissions: session?.permissions ?? [] }}>
|
||||
{children}
|
||||
</SuperAdminSessionProvider>
|
||||
);
|
||||
}
|
||||
18
oudelaa_dashboard/components/auth/no-permission-state.tsx
Normal file
18
oudelaa_dashboard/components/auth/no-permission-state.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
|
||||
export function NoPermissionState({
|
||||
description,
|
||||
title = "Access restricted",
|
||||
}: {
|
||||
description: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<EmptyState title={title} description={description} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
33
oudelaa_dashboard/components/auth/session-context.tsx
Normal file
33
oudelaa_dashboard/components/auth/session-context.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { SuperAdminSessionResponse } from "@/types/api";
|
||||
|
||||
type SuperAdminSessionContextValue = {
|
||||
session: SuperAdminSessionResponse | null;
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
const SuperAdminSessionContext = createContext<SuperAdminSessionContextValue>({
|
||||
session: null,
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
export function SuperAdminSessionProvider({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: SuperAdminSessionContextValue;
|
||||
}) {
|
||||
return (
|
||||
<SuperAdminSessionContext.Provider value={value}>
|
||||
{children}
|
||||
</SuperAdminSessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSuperAdminSession() {
|
||||
return useContext(SuperAdminSessionContext);
|
||||
}
|
||||
76
oudelaa_dashboard/components/dashboard/charts.tsx
Normal file
76
oudelaa_dashboard/components/dashboard/charts.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import type { ChannelPoint, Insight, RevenuePoint } from "@/types";
|
||||
|
||||
const pieColors = ["hsl(var(--chart-1))", "hsl(var(--chart-2))", "hsl(var(--chart-3))", "hsl(var(--chart-4))"];
|
||||
|
||||
export function RevenueAreaChart({ data }: { data: RevenuePoint[] }) {
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer>
|
||||
<AreaChart data={data}>
|
||||
<CartesianGrid stroke="rgba(180,150,110,0.14)" vertical={false} />
|
||||
<XAxis dataKey="month" tick={{ fill: "#c6b698", fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "#a79577", fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip
|
||||
cursor={{ stroke: "rgba(214,170,100,0.35)" }}
|
||||
contentStyle={{ background: "#20160f", border: "1px solid #5e4732", borderRadius: "12px" }}
|
||||
/>
|
||||
<Legend />
|
||||
<Area type="monotone" dataKey="revenue" name="الإيراد" stroke="hsl(var(--chart-1))" fill="hsl(var(--chart-1))" fillOpacity={0.2} strokeWidth={2.5} />
|
||||
<Area type="monotone" dataKey="orders" name="الطلبات" stroke="hsl(var(--chart-2))" fill="hsl(var(--chart-2))" fillOpacity={0.16} strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelPieChart({ data }: { data: ChannelPoint[] }) {
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer>
|
||||
<PieChart>
|
||||
<Tooltip contentStyle={{ background: "#20160f", border: "1px solid #5e4732", borderRadius: "12px" }} />
|
||||
<Pie data={data} dataKey="value" nameKey="name" innerRadius={58} outerRadius={94} paddingAngle={4}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={entry.name} fill={pieColors[index % pieColors.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InsightBarChart({ data }: { data: Insight[] }) {
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid stroke="rgba(180,150,110,0.14)" vertical={false} />
|
||||
<XAxis dataKey="label" tick={{ fill: "#c6b698", fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fill: "#a79577", fontSize: 12 }} axisLine={false} tickLine={false} />
|
||||
<Tooltip contentStyle={{ background: "#20160f", border: "1px solid #5e4732", borderRadius: "12px" }} />
|
||||
<Bar dataKey="value" radius={[8, 8, 0, 0]} fill="hsl(var(--chart-1))" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
oudelaa_dashboard/components/dashboard/mobile-nav.tsx
Normal file
42
oudelaa_dashboard/components/dashboard/mobile-nav.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||
import { dashboardNav } from "@/lib/navigation";
|
||||
import { matchesPermissions } from "@/lib/permissions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function MobileNav() {
|
||||
const pathname = usePathname();
|
||||
const { permissions } = useSuperAdminSession();
|
||||
const accessibleNav = useMemo(
|
||||
() =>
|
||||
dashboardNav.filter((item) =>
|
||||
matchesPermissions(permissions, item.requiredPermissions, item.permissionMode),
|
||||
),
|
||||
[permissions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex gap-2 overflow-x-auto pb-1 lg:hidden">
|
||||
{accessibleNav.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"whitespace-nowrap rounded-full border px-3 py-1.5 text-xs",
|
||||
active ? "border-primary/40 bg-primary/20 text-primary" : "border-border bg-card text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
oudelaa_dashboard/components/dashboard/page-header.tsx
Normal file
22
oudelaa_dashboard/components/dashboard/page-header.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PageHeaderProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageHeader({ title, subtitle, actions, className }: PageHeaderProps) {
|
||||
return (
|
||||
<header className={cn("page-enter flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between", className)}>
|
||||
<div>
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight text-foreground sm:text-3xl">{title}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { PaginationMeta } from "@/types/api";
|
||||
|
||||
type PaginationControlsProps = {
|
||||
pagination?: PaginationMeta | null;
|
||||
loading?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
};
|
||||
|
||||
export function PaginationControls({ pagination, loading, onPageChange }: PaginationControlsProps) {
|
||||
if (!pagination) return null;
|
||||
|
||||
const currentPage = pagination.page ?? 1;
|
||||
const totalPages = pagination.totalPages ?? 1;
|
||||
const previousPage = pagination.previousPage ?? (currentPage > 1 ? currentPage - 1 : null);
|
||||
const nextPage = pagination.nextPage ?? (currentPage < totalPages ? currentPage + 1 : null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/60 p-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages} · Total {pagination.total}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || !previousPage}
|
||||
onClick={() => previousPage && onPageChange(previousPage)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || !nextPage}
|
||||
onClick={() => nextPage && onPageChange(nextPage)}
|
||||
>
|
||||
Next
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
oudelaa_dashboard/components/dashboard/post-preview-card.tsx
Normal file
157
oudelaa_dashboard/components/dashboard/post-preview-card.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AudioLines, Images, PlayCircle } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { getPostAuthor, getPostPreviewMedia, getUserLabel } from "@/lib/post-utils";
|
||||
import type { ApiPost } from "@/types/api";
|
||||
|
||||
function formatDuration(durationSeconds?: number | null) {
|
||||
if (typeof durationSeconds !== "number" || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(durationSeconds / 60);
|
||||
const seconds = Math.floor(durationSeconds % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function PostPreviewCard({ post }: { post: ApiPost }) {
|
||||
const author = getPostAuthor(post);
|
||||
const media = getPostPreviewMedia(post);
|
||||
const likes = post.engagement?.likesCount ?? post.likesCount ?? 0;
|
||||
const comments = post.engagement?.commentsCount ?? post.commentsCount ?? 0;
|
||||
const shares = post.engagement?.shareCount ?? post.shareCount ?? 0;
|
||||
const waveformPeaks = Array.isArray(post.waveformPeaks) && post.waveformPeaks.length ? post.waveformPeaks : [18, 32, 24, 44, 28, 40, 22, 36, 26, 30];
|
||||
const durationLabel = formatDuration(post.durationSeconds);
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
const [sourceFailed, setSourceFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setImageFailed(false);
|
||||
setSourceFailed(false);
|
||||
}, [media.url, media.sourceUrl]);
|
||||
|
||||
const showAudioPreview = media.kind === "audio" && !!media.sourceUrl && !sourceFailed;
|
||||
const showVideoPreview = media.kind === "video" && !!media.sourceUrl && !sourceFailed && (!media.url || imageFailed);
|
||||
const showImagePreview = !!media.url && !imageFailed;
|
||||
const showMediaShell = media.kind !== "text";
|
||||
const showUnavailable = !showImagePreview && !showVideoPreview && !showAudioPreview;
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-border/70">
|
||||
<CardContent className="p-0">
|
||||
{showMediaShell ? (
|
||||
<div className="relative aspect-[16/9] border-b border-border/70 bg-secondary/30">
|
||||
{showImagePreview ? (
|
||||
<Image
|
||||
src={media.url}
|
||||
alt={post.content || post.postType || "post"}
|
||||
fill
|
||||
unoptimized
|
||||
loading="lazy"
|
||||
onError={() => setImageFailed(true)}
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{showVideoPreview ? (
|
||||
<video
|
||||
src={media.sourceUrl}
|
||||
preload="metadata"
|
||||
muted
|
||||
playsInline
|
||||
onError={() => setSourceFailed(true)}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{showAudioPreview ? (
|
||||
<div className="absolute inset-0 flex flex-col justify-between bg-gradient-to-br from-secondary/70 via-background/60 to-secondary/80 p-5">
|
||||
<div className="flex items-center justify-between gap-3 text-sm text-foreground">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-background/70 px-3 py-1">
|
||||
<AudioLines className="h-4 w-4" />
|
||||
<span>Audio preview</span>
|
||||
</div>
|
||||
{durationLabel ? <span className="text-xs text-muted-foreground">{durationLabel}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex h-24 items-end justify-center gap-1.5 px-2">
|
||||
{waveformPeaks.slice(0, 48).map((peak, index) => {
|
||||
const safePeak = Number.isFinite(peak) ? Math.max(8, Math.min(100, peak)) : 20;
|
||||
return (
|
||||
<span
|
||||
key={`${post._id}-peak-${index}`}
|
||||
className="w-1.5 rounded-full bg-primary/80"
|
||||
style={{ height: `${safePeak}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<audio
|
||||
src={media.sourceUrl}
|
||||
preload="metadata"
|
||||
controls
|
||||
onError={() => setSourceFailed(true)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showUnavailable ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4 text-center text-sm text-muted-foreground">
|
||||
Media preview unavailable
|
||||
</div>
|
||||
) : null}
|
||||
<div className="absolute left-3 top-3 flex items-center gap-2">
|
||||
<Badge variant="warning">{post.postType ?? "post"}</Badge>
|
||||
{media.kind === "image" && media.count > 1 ? (
|
||||
<Badge variant="muted">
|
||||
<Images className="mr-1 h-3 w-3" />
|
||||
{media.count}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute bottom-3 right-3 rounded-full border border-border/60 bg-background/85 p-2 text-foreground">
|
||||
{media.kind === "audio" ? <AudioLines className="h-4 w-4" /> : <PlayCircle className="h-4 w-4" />}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="muted">{post.visibility ?? "public"}</Badge>
|
||||
<Badge
|
||||
variant={
|
||||
post.moderationStatus === "hidden"
|
||||
? "danger"
|
||||
: post.moderationStatus === "flagged"
|
||||
? "warning"
|
||||
: "success"
|
||||
}
|
||||
>
|
||||
{post.moderationStatus ?? "active"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">{getUserLabel(author)}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">{formatDateTime(post.createdAt)}</div>
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-4 text-sm leading-6 text-foreground">
|
||||
{post.content?.trim() || "Media post without caption."}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{likes} likes</span>
|
||||
<span>{comments} comments</span>
|
||||
<span>{shares} shares</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
5
oudelaa_dashboard/components/dashboard/section-grid.tsx
Normal file
5
oudelaa_dashboard/components/dashboard/section-grid.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SectionGrid({ children }: { children: ReactNode }) {
|
||||
return <section className="grid gap-4 xl:grid-cols-12">{children}</section>;
|
||||
}
|
||||
84
oudelaa_dashboard/components/dashboard/sidebar.tsx
Normal file
84
oudelaa_dashboard/components/dashboard/sidebar.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Search, ShieldCheck } from "lucide-react";
|
||||
|
||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { dashboardNav } from "@/lib/navigation";
|
||||
import { matchesPermissions } from "@/lib/permissions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DashboardSidebar() {
|
||||
const pathname = usePathname();
|
||||
const [search, setSearch] = useState("");
|
||||
const { permissions } = useSuperAdminSession();
|
||||
|
||||
const accessibleNav = useMemo(
|
||||
() =>
|
||||
dashboardNav.filter((item) =>
|
||||
matchesPermissions(permissions, item.requiredPermissions, item.permissionMode),
|
||||
),
|
||||
[permissions],
|
||||
);
|
||||
|
||||
const filteredNav = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return accessibleNav;
|
||||
return accessibleNav.filter((item) => item.label.toLowerCase().includes(needle));
|
||||
}, [accessibleNav, search]);
|
||||
|
||||
return (
|
||||
<aside className="frame-panel ornament-grid sticky top-4 hidden h-[calc(100vh-2rem)] w-72 shrink-0 flex-col border-border/70 p-4 lg:flex">
|
||||
<div className="mb-5 rounded-xl border border-border/60 bg-background/70 p-4">
|
||||
<p className="font-heading text-xl font-bold tracking-wide text-primary">Oudelaa</p>
|
||||
<p className="text-xs text-muted-foreground">SuperAdmin Command Console</p>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Search className="pointer-events-none absolute right-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pr-9"
|
||||
placeholder="Search sections..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1">
|
||||
{filteredNav.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-lg border px-3 py-2 text-sm transition",
|
||||
isActive
|
||||
? "border-primary/40 bg-primary/15 text-primary"
|
||||
: "border-transparent text-muted-foreground hover:border-border hover:bg-secondary/70 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto space-y-3 rounded-xl border border-border/70 bg-secondary/40 p-4">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Moderation mode</span>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm text-foreground">
|
||||
This console manages users, marketplace items, content, reports, and SuperAdmin sessions
|
||||
through dedicated admin API routes.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Data is loaded from the live API, not mock data.</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
31
oudelaa_dashboard/components/dashboard/stat-card.tsx
Normal file
31
oudelaa_dashboard/components/dashboard/stat-card.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ArrowDownLeft, ArrowUpRight, Minus } from "lucide-react";
|
||||
|
||||
import type { StatMetric } from "@/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const trendStyles = {
|
||||
up: "text-emerald-300",
|
||||
down: "text-rose-300",
|
||||
neutral: "text-amber-200",
|
||||
} as const;
|
||||
|
||||
export function StatCard({ metric }: { metric: StatMetric }) {
|
||||
const Icon = metric.trend === "up" ? ArrowUpRight : metric.trend === "down" ? ArrowDownLeft : Minus;
|
||||
|
||||
return (
|
||||
<Card className="ornament-grid">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-semibold text-muted-foreground">{metric.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-foreground">{metric.value}</div>
|
||||
<p className={cn("mt-1 flex items-center gap-1 text-xs", trendStyles[metric.trend])}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{metric.delta}
|
||||
</p>
|
||||
{metric.note ? <p className="mt-2 text-xs text-muted-foreground">{metric.note}</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
276
oudelaa_dashboard/components/dashboard/topbar.tsx
Normal file
276
oudelaa_dashboard/components/dashboard/topbar.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Bell, MoonStar, RefreshCcw, ShieldCheck, SunMedium } from "lucide-react";
|
||||
|
||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer } from "@/components/ui/drawer";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { useTheme } from "@/components/theme/theme-provider";
|
||||
import { listSuperAdminSessions } from "@/lib/api/auth";
|
||||
import { listPlatformNotifications } from "@/lib/api/notifications";
|
||||
import { getSuperAdminOverview, getSuperAdminRecentActivity } from "@/lib/api/superadmin";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
|
||||
import type {
|
||||
NotificationItem,
|
||||
NotificationsResponse,
|
||||
SessionItem,
|
||||
SessionsResponse,
|
||||
SuperAdminOverviewResponse,
|
||||
SuperAdminRecentActivityItem,
|
||||
SuperAdminRecentActivityResponse,
|
||||
} from "@/types/api";
|
||||
|
||||
const EMPTY_NOTIFICATIONS: NotificationsResponse = { items: [], data: [], unreadCount: 0 };
|
||||
const EMPTY_SESSIONS: SessionsResponse = { items: [] };
|
||||
const EMPTY_ACTIVITY: SuperAdminRecentActivityResponse = { items: [] };
|
||||
|
||||
export function DashboardTopbar() {
|
||||
const { toast } = useToast();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { permissions } = useSuperAdminSession();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [overview, setOverview] = useState<SuperAdminOverviewResponse | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<SuperAdminRecentActivityItem[]>([]);
|
||||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||||
const [sessions, setSessions] = useState<SessionItem[]>([]);
|
||||
|
||||
const canReadOverview = hasPermission(permissions, SUPERADMIN_PERMISSIONS.OVERVIEW_READ);
|
||||
const canReadAnalytics = hasPermission(permissions, SUPERADMIN_PERMISSIONS.ANALYTICS_READ);
|
||||
const canReadNotifications = hasPermission(permissions, SUPERADMIN_PERMISSIONS.NOTIFICATIONS_READ);
|
||||
const canManageSessions = hasPermission(permissions, SUPERADMIN_PERMISSIONS.SESSIONS_MANAGE);
|
||||
|
||||
const todayLabel = useMemo(() => {
|
||||
try {
|
||||
return new Intl.DateTimeFormat("ar-SA", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(new Date());
|
||||
} catch {
|
||||
return "Today";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const unreadCount =
|
||||
overview?.metrics.unreadNotificationsCount ??
|
||||
notifications.filter((item) => item.read === false).length;
|
||||
const moderationAttentionCount =
|
||||
(overview?.metrics.flaggedPostsCount ?? 0) + (overview?.metrics.flaggedCommentsCount ?? 0);
|
||||
|
||||
const summaryCards = [
|
||||
canManageSessions
|
||||
? {
|
||||
key: "sessions",
|
||||
label: "SuperAdmin sessions",
|
||||
value: String(sessions.length),
|
||||
}
|
||||
: null,
|
||||
canReadNotifications
|
||||
? {
|
||||
key: "notifications",
|
||||
label: "Unread notifications",
|
||||
value: String(unreadCount),
|
||||
}
|
||||
: null,
|
||||
canReadOverview
|
||||
? {
|
||||
key: "moderation",
|
||||
label: "Needs review",
|
||||
value: String(moderationAttentionCount),
|
||||
}
|
||||
: null,
|
||||
].filter(Boolean) as Array<{ key: string; label: string; value: string }>;
|
||||
const summaryGridClass =
|
||||
summaryCards.length >= 3
|
||||
? "grid gap-3 sm:grid-cols-3"
|
||||
: summaryCards.length === 2
|
||||
? "grid gap-3 sm:grid-cols-2"
|
||||
: "grid gap-3";
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [overviewResponse, activityResponse, notificationsResponse, sessionsResponse] =
|
||||
await Promise.all([
|
||||
canReadOverview ? getSuperAdminOverview() : Promise.resolve(null),
|
||||
canReadAnalytics
|
||||
? getSuperAdminRecentActivity({ limit: 5 })
|
||||
: Promise.resolve(EMPTY_ACTIVITY),
|
||||
canReadNotifications
|
||||
? listPlatformNotifications({ limit: 5, sortOrder: "desc" })
|
||||
: Promise.resolve(EMPTY_NOTIFICATIONS),
|
||||
canManageSessions ? listSuperAdminSessions() : Promise.resolve(EMPTY_SESSIONS),
|
||||
]);
|
||||
setOverview(overviewResponse);
|
||||
setRecentActivity(activityResponse.items ?? []);
|
||||
setNotifications(notificationsResponse.items ?? notificationsResponse.data ?? []);
|
||||
setSessions(sessionsResponse.items ?? []);
|
||||
} catch (error) {
|
||||
toast({ title: "Failed to load command center", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canManageSessions, canReadAnalytics, canReadNotifications, canReadOverview, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
}, [loadOverview]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="page-enter mb-5 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 bg-card/80 p-3 backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="warning">SuperAdmin</Badge>
|
||||
<p className="text-sm text-muted-foreground">{todayLabel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => void loadOverview()} disabled={loading}>
|
||||
<RefreshCcw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
toggle();
|
||||
toast({
|
||||
title: "Theme updated",
|
||||
description: theme === "dark" ? "Switched to light mode." : "Switched to dark mode.",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{theme === "dark" ? <SunMedium className="h-4 w-4" /> : <MoonStar className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setDrawerOpen(true)}>
|
||||
<Bell className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
title="Operations Center"
|
||||
description="A quick view of the latest notifications, activity, and active sessions."
|
||||
side="right"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{summaryCards.length ? (
|
||||
<div className={summaryGridClass}>
|
||||
{summaryCards.map((card) => (
|
||||
<div key={card.key} className="rounded-xl border border-border bg-secondary/40 p-4">
|
||||
<div className="text-xs text-muted-foreground">{card.label}</div>
|
||||
<div className="mt-2 text-2xl font-bold text-foreground">{card.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canReadNotifications ? (
|
||||
<div className="rounded-xl border border-border bg-secondary/40 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Latest notifications</p>
|
||||
<Link href="/notifications" className="text-xs font-semibold text-primary">
|
||||
Open page
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{notifications.length ? (
|
||||
notifications.map((item) => (
|
||||
<div key={item._id} className="rounded-lg border border-border/60 bg-background/40 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-foreground">{item.title ?? item.type}</p>
|
||||
<Badge variant={item.read ? "muted" : "warning"}>
|
||||
{item.read ? "Read" : "New"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{item.previewText ?? item.deepLink ?? "-"}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No recent notifications.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canReadAnalytics ? (
|
||||
<div className="rounded-xl border border-border bg-secondary/40 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-foreground">Recent activity</p>
|
||||
<Link href="/dashboard" className="text-xs font-semibold text-primary">
|
||||
Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{recentActivity.length ? (
|
||||
recentActivity.map((item, index) => (
|
||||
<div
|
||||
key={`${item.type}-${item.createdAt ?? index}`}
|
||||
className="rounded-lg border border-border/60 bg-background/40 p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium text-foreground">{item.title}</p>
|
||||
<Badge
|
||||
variant={
|
||||
item.status === "flagged"
|
||||
? "warning"
|
||||
: item.status === "hidden"
|
||||
? "danger"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.subtitle}</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">{formatDateTime(item.createdAt)}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No recent activity.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canManageSessions ? (
|
||||
<div className="rounded-xl border border-border bg-secondary/40 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-semibold text-foreground">Active sessions</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{sessions.length ? (
|
||||
sessions.map((item) => (
|
||||
<div
|
||||
key={item.id ?? item.jti}
|
||||
className="rounded-lg border border-border/60 bg-background/40 p-3"
|
||||
>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{item.id ?? item.jti ?? "session"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatDateTime(item.createdAt)} - {formatDateTime(item.expiresAt)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No active sessions to display.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
oudelaa_dashboard/components/theme/theme-provider.tsx
Normal file
49
oudelaa_dashboard/components/theme/theme-provider.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
toggle: () => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = "oudelaa.theme";
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("theme-dark", "theme-light");
|
||||
root.classList.add(`theme-${theme}`);
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>("dark");
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY) as Theme | null;
|
||||
const initial = stored ?? "dark";
|
||||
setTheme(initial);
|
||||
applyTheme(initial);
|
||||
}, []);
|
||||
|
||||
const toggle = () => {
|
||||
setTheme((prev) => {
|
||||
const next = prev === "dark" ? "light" : "dark";
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
applyTheme(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
30
oudelaa_dashboard/components/ui/badge.tsx
Normal file
30
oudelaa_dashboard/components/ui/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary/20 text-primary",
|
||||
success: "bg-emerald-500/15 text-emerald-300",
|
||||
warning: "bg-amber-500/15 text-amber-200",
|
||||
danger: "bg-rose-500/15 text-rose-200",
|
||||
muted: "bg-secondary text-muted-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
49
oudelaa_dashboard/components/ui/button.tsx
Normal file
49
oudelaa_dashboard/components/ui/button.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "bg-transparent text-foreground hover:bg-secondary/60",
|
||||
outline: "border border-border bg-transparent text-foreground hover:bg-secondary/60",
|
||||
danger: "bg-red-900/70 text-red-100 hover:bg-red-800/80",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2 text-sm",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-11 px-5 text-sm",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, type, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type={type ?? "button"}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
30
oudelaa_dashboard/components/ui/card.tsx
Normal file
30
oudelaa_dashboard/components/ui/card.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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("frame-panel gold-edge", className)} {...props} />
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col gap-1.5 p-5", className)} {...props} />
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("font-heading text-lg font-semibold 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-5 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export { Card, CardContent, CardDescription, CardHeader, CardTitle };
|
||||
62
oudelaa_dashboard/components/ui/dialog.tsx
Normal file
62
oudelaa_dashboard/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"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/70 backdrop-blur-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = "DialogOverlay";
|
||||
|
||||
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 left-1/2 top-1/2 z-50 grid w-[95vw] max-w-xl -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border border-border bg-card p-6 shadow-glow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogClose className="absolute left-4 top-4 rounded-md p-1 text-muted-foreground hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = "DialogContent";
|
||||
|
||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("space-y-2 text-right", className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h2 className={cn("font-heading text-xl font-bold", className)} {...props} />;
|
||||
}
|
||||
|
||||
function DialogDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger };
|
||||
58
oudelaa_dashboard/components/ui/drawer.tsx
Normal file
58
oudelaa_dashboard/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type DrawerProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
side?: "left" | "right";
|
||||
widthClassName?: string;
|
||||
};
|
||||
|
||||
export function Drawer({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
side = "left",
|
||||
widthClassName,
|
||||
}: DrawerProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-40 bg-black/50 backdrop-blur-sm transition",
|
||||
open ? "opacity-100" : "pointer-events-none opacity-0",
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed bottom-0 top-0 z-50 w-[96vw] max-w-md border border-border bg-card p-5 shadow-glow transition",
|
||||
widthClassName,
|
||||
side === "left" ? "left-0" : "right-0",
|
||||
open ? "translate-x-0" : side === "left" ? "-translate-x-full" : "translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className="mb-5 flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-heading text-lg font-bold">{title}</h3>
|
||||
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-[calc(100%-72px)] overflow-y-auto">{children}</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
oudelaa_dashboard/components/ui/empty-state.tsx
Normal file
21
oudelaa_dashboard/components/ui/empty-state.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Music2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type EmptyStateProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function EmptyState({ title, description, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn("ornament-grid shimmer-empty rounded-xl border border-dashed border-border p-8 text-center", className)}>
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-secondary text-primary">
|
||||
<Music2 className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="font-heading text-lg font-semibold">{title}</h3>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
oudelaa_dashboard/components/ui/input.tsx
Normal file
20
oudelaa_dashboard/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
71
oudelaa_dashboard/components/ui/select.tsx
Normal file
71
oudelaa_dashboard/components/ui/select.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
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-lg border border-input bg-background/60 px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = "SelectTrigger";
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn("z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = "SelectContent";
|
||||
|
||||
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 cursor-default select-none items-center rounded-md py-2 pl-8 pr-3 text-sm outline-none hover:bg-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-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 = "SelectItem";
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
export { Select, SelectContent, SelectItem, SelectTrigger, SelectValue };
|
||||
9
oudelaa_dashboard/components/ui/separator.tsx
Normal file
9
oudelaa_dashboard/components/ui/separator.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Separator({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div role="separator" className={cn("h-px w-full bg-border", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
32
oudelaa_dashboard/components/ui/switch.tsx
Normal file
32
oudelaa_dashboard/components/ui/switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SwitchProps = {
|
||||
checked: boolean;
|
||||
onCheckedChange: (value: boolean) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Switch({ checked, onCheckedChange, className }: SwitchProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
"relative h-6 w-11 rounded-full border border-border transition",
|
||||
checked ? "bg-primary" : "bg-secondary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-0.5 h-[18px] w-[18px] rounded-full bg-background transition",
|
||||
checked ? "right-0.5" : "right-[22px]",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
37
oudelaa_dashboard/components/ui/table.tsx
Normal file
37
oudelaa_dashboard/components/ui/table.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(({ className, ...props }, ref) => (
|
||||
<div className="w-full overflow-auto">
|
||||
<table ref={ref} className={cn("w-full caption-bottom text-sm", className)} {...props} />
|
||||
</div>
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b [&_tr]:border-border", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(({ className, ...props }, ref) => (
|
||||
<tr ref={ref} className={cn("border-b border-border/70 transition hover:bg-secondary/40", className)} {...props} />
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(({ className, ...props }, ref) => (
|
||||
<th ref={ref} className={cn("h-11 px-4 text-right align-middle font-medium text-muted-foreground", className)} {...props} />
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("p-4 align-middle", className)} {...props} />
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow };
|
||||
22
oudelaa_dashboard/components/ui/textarea.tsx
Normal file
22
oudelaa_dashboard/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-[90px] w-full rounded-lg border border-input bg-background/60 px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
80
oudelaa_dashboard/components/ui/toast.tsx
Normal file
80
oudelaa_dashboard/components/ui/toast.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ToastVariant = "default" | "success" | "warning" | "danger";
|
||||
|
||||
type ToastItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: ToastVariant;
|
||||
};
|
||||
|
||||
type ToastContextValue = {
|
||||
toast: (item: Omit<ToastItem, "id">) => void;
|
||||
};
|
||||
|
||||
const ToastContext = React.createContext<ToastContextValue | null>(null);
|
||||
|
||||
function getToastId() {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `toast_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
const variantStyles: Record<ToastVariant, string> = {
|
||||
default: "border-border bg-card text-foreground",
|
||||
success: "border-emerald-500/40 bg-emerald-500/10 text-emerald-100",
|
||||
warning: "border-amber-500/40 bg-amber-500/10 text-amber-100",
|
||||
danger: "border-rose-500/40 bg-rose-500/10 text-rose-100",
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = React.useState<ToastItem[]>([]);
|
||||
|
||||
const toast = React.useCallback((item: Omit<ToastItem, "id">) => {
|
||||
const id = getToastId();
|
||||
setToasts((prev) => [...prev, { id, ...item }]);
|
||||
window.setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((toastItem) => toastItem.id !== id));
|
||||
}, 3200);
|
||||
}, []);
|
||||
|
||||
const remove = React.useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((toastItem) => toastItem.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast }}>
|
||||
{children}
|
||||
<div className="fixed bottom-6 left-6 z-50 flex w-[90vw] max-w-sm flex-col gap-3">
|
||||
{toasts.map((item) => (
|
||||
<div key={item.id} className={cn("frame-panel border px-4 py-3 shadow-glow", variantStyles[item.variant ?? "default"])}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{item.title}</p>
|
||||
{item.description ? <p className="mt-1 text-xs text-muted-foreground">{item.description}</p> : null}
|
||||
</div>
|
||||
<button className="rounded-md p-1 text-muted-foreground hover:text-foreground" onClick={() => remove(item.id)}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = React.useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error("useToast must be used within ToastProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم