This commit is contained in:
rgrgogu
2026-05-14 12:59:31 +08:00
parent 6e98ff0b5d
commit dcf5f43fd5
38 changed files with 2174 additions and 382 deletions
+50 -5
View File
@@ -1,9 +1,54 @@
// modules/admin/pages/UsersDashboard.jsx
import { Tabs, TabsList, TabsTrigger } from "@/components/custom/original-tabs";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import DashboardGrid from "@/components/generic/DashboardGrid";
import { ADMIN_SECTIONS } from "@/data/adminTiles.data";
function scrollTo(sectionId) {
document.getElementById(sectionId)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
export default function AdminDashboard() {
return (
<div>
AdminDashboard
</div>
);
return (
<div>
{/* ── Sticky tab bar — driven by the same array ── */}
<div className="sticky top-0 z-10 bg-background border-b">
<Tabs defaultValue="">
<ScrollArea className="max-w-full overflow-x-auto w-full">
<TabsList className="bg-background rounded-none justify-start mx-2 my-1 flex gap-1">
{ADMIN_SECTIONS.map((s) => (
<TabsTrigger
key={s.id}
value={s.id}
onClick={() => s.id === ADMIN_SECTIONS[0].id
? window.scrollTo({ top: 0, behavior: "smooth" })
: scrollTo(s.id)
}
className="data-[state=active]:bg-muted data-[state=active]:shadow-none hover:bg-muted text-muted-foreground data-[state=active]:text-foreground"
>
{s.tab}
</TabsTrigger>
))}
</TabsList>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</Tabs>
</div>
{/* ── Sections — same array, one DashboardGrid per entry ── */}
{ADMIN_SECTIONS.map((s) => (
<div key={s.id} id={s.id} className="scroll-mt-12 min-h-64">
{s.tiles.length > 0 ? (
<DashboardGrid sections={[s]} />
) : (
<div className="px-4 lg:container lg:mx-auto py-10">
<h1 className="text-2xl font-medium tracking-tighter mb-1">{s.title}</h1>
<p className="text-muted-foreground text-sm">No items yet.</p>
</div>
)}
</div>
))}
</div>
);
}
+351
View File
@@ -0,0 +1,351 @@
// modules/admin/pages/assets/AddAsset.jsx
import { useState, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image } from "lucide-react";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// ─── Derive file_type from MIME type ──────────────────────────────────────────
function resolveFileType(mimeType = "") {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
return "image";
}
// ─── Schema ───────────────────────────────────────────────────────────────────
const schema = z.object({
display_name: z.string().min(1, "Display name is required."),
description: z.string().optional(),
is_public: z.enum(["true", "false"]),
storage_provider: z.enum(["chibisafe", "local", "s3"]),
});
// ─── File type icon ───────────────────────────────────────────────────────────
function FileTypeIcon({ mimeType = "" }) {
if (mimeType.startsWith("video/")) return <FileVideo className="h-10 w-10 text-blue-400" />;
if (mimeType.startsWith("image/")) return <Image className="h-10 w-10 text-green-400" />;
return <FileText className="h-10 w-10 text-orange-400" />;
}
// ─── Drop zone ────────────────────────────────────────────────────────────────
function DropZone({ label, accept, file, onFile, onClear, error }) {
const inputRef = useRef(null);
return (
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) onFile(f); }}
onClick={() => !file && inputRef.current?.click()}
className={[
"rounded-lg border-2 border-dashed transition-colors cursor-pointer",
error ? "border-destructive/60 bg-destructive/5" : "",
file ? "border-border bg-muted/30 cursor-default" : "",
!file && !error ? "border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20" : "",
].join(" ")}
>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => { if (e.target.files[0]) onFile(e.target.files[0]); }}
/>
{file ? (
<div className="flex items-center gap-3 p-4">
<FileTypeIcon mimeType={file.type} />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{file.name}</p>
<p className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)} KB · {file.type}
</p>
</div>
<Button
type="button" variant="ghost" size="icon"
onClick={(e) => { e.stopPropagation(); onClear(); }}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-2 py-10 px-4">
<UploadCloud className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground text-center">
Drag & drop or <span className="text-primary font-medium">browse</span> to upload
<br />
<span className="text-xs">{label}</span>
</p>
</div>
)}
</div>
);
}
// ─── Field error ──────────────────────────────────────────────────────────────
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function AddAsset() {
const navigate = useNavigate();
const { uploadAsset, loading } = useAssets();
const { user } = useAuth();
const fileRef = useRef(null);
const thumbnailRef = useRef(null);
const [thumbKey, setThumbKey] = useState(0);
const {
register,
control,
handleSubmit,
setValue,
watch,
setError,
clearErrors,
formState: { errors },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
display_name: "",
description: "",
is_public: "false",
storage_provider: "chibisafe",
},
});
const file = watch("_file");
const isVideo = file?.type?.startsWith("video/");
// ── Auto-derive file_type from MIME ───────────────────────────────────────
const fileType = file ? resolveFileType(file.type) : null;
const setFile = (f) => {
fileRef.current = f;
setValue("_file", f);
if (!watch("display_name")) setValue("display_name", f.name);
clearErrors("_file");
};
const setThumbnail = (f) => {
thumbnailRef.current = f;
setValue("_thumbnail", f);
clearErrors("_thumbnail");
};
const onSubmit = async (data) => {
let hasFileError = false;
if (!fileRef.current) {
setError("_file", { message: "A file is required." });
hasFileError = true;
}
if (isVideo && !thumbnailRef.current) {
setError("_thumbnail", { message: "A thumbnail is required for video uploads." });
hasFileError = true;
}
if (hasFileError) return;
const result = await uploadAsset({
file: fileRef.current,
thumbnail: thumbnailRef.current ?? undefined,
display_name: data.display_name,
description: data.description ?? "",
file_type: fileType,
is_public: data.is_public === "true",
storage_provider: data.storage_provider,
createdBy: user?.user_id,
});
if (result) navigate(-1);
};
return (
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Add Asset</h1>
<p className="text-sm text-muted-foreground">Upload a new file to the asset library.</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="rounded-lg border bg-card p-6 space-y-5">
{/* ── File ── */}
<div className="space-y-1.5">
<Label>File <span className="text-destructive">*</span></Label>
<DropZone
label="Images, videos, documents"
accept="image/*,video/*,application/*,text/*"
file={fileRef.current}
onFile={setFile}
onClear={() => {
fileRef.current = null;
setValue("_file", null);
setValue("display_name", "");
clearErrors("_file");
}}
error={errors._file?.message}
/>
<FieldError message={errors._file?.message} />
</div>
{/* ── Thumbnail (video only) ── */}
{isVideo && (
<div className="space-y-1.5">
<Label>Thumbnail <span className="text-destructive">*</span></Label>
<DropZone
key={thumbKey}
label="JPEG, PNG (video thumbnail)"
accept="image/*"
file={thumbnailRef.current}
onFile={setThumbnail}
onClear={() => {
thumbnailRef.current = null;
setValue("_thumbnail", null);
clearErrors("_thumbnail");
setThumbKey((k) => k + 1);
}}
error={errors._thumbnail?.message}
/>
<FieldError message={errors._thumbnail?.message} />
</div>
)}
{/* ── Display Name ── */}
<div className="space-y-1.5">
<Label htmlFor="display_name">
Display Name <span className="text-destructive">*</span>
</Label>
<Input
id="display_name"
placeholder="Friendly name for this asset"
{...register("display_name")}
/>
<FieldError message={errors.display_name?.message} />
</div>
{/* ── Description ── */}
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Optional description"
rows={3}
{...register("description")}
/>
</div>
{/* ── File Type · Access · Storage (one row) ── */}
<div className="grid grid-cols-3 gap-4">
{/* File Type — auto-derived, disabled */}
<div className="space-y-1.5">
<Label>File Type</Label>
<Select value={fileType ?? ""} disabled>
<SelectTrigger className="disabled:opacity-60 disabled:cursor-not-allowed">
<SelectValue placeholder="Auto-detected" />
</SelectTrigger>
<SelectContent>
<SelectItem value="avatar">Avatar</SelectItem>
<SelectItem value="image">Image</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="document">Document</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Detected from file</p>
</div>
{/* Access */}
<div className="space-y-1.5">
<Label>Access</Label>
<Controller
name="is_public"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="false">Private</SelectItem>
<SelectItem value="true">Public</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
{/* Storage Provider */}
<div className="space-y-1.5">
<Label>Storage</Label>
<Controller
name="storage_provider"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="chibisafe">Chibisafe</SelectItem>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</div>
</div>
{/* ── Actions ── */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Upload Asset
</Button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,26 @@
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import ArchivedAssetsTable from "../../components/assets/ArchivedAssetsTable";
export default function ArchivedAssetList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "Assets", to: `/admin/assets` },
{ label: "Archived" },
];
return (
<section className="bg-muted/60 h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<ArchivedAssetsTable />
</div>
</div>
</section>
);
}
@@ -0,0 +1,25 @@
import { House } from "lucide-react";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import AssetsTable from "../../components/assets/AssetsTable";
export default function AssetList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "Assets" },
]
return (
<section className="bg-muted/60 h-full">
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
<div className="flex flex-col gap-2 my-6 ">
<AppBreadcrumb items={items} />
</div>
<div className="w-full">
<AssetsTable />
</div>
</div>
</section>
)
}
@@ -0,0 +1,345 @@
// modules/admin/pages/assets/EditAsset.jsx
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, RefreshCw } from "lucide-react";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// ─── Schema ───────────────────────────────────────────────────────────────────
const schema = z.object({
display_name: z.string().min(1, "Display name is required."),
description: z.string().optional(),
is_public: z.enum(["true", "false"]),
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function FileTypeIcon({ mimeType = "" }) {
if (mimeType?.startsWith("video/")) return <FileVideo className="h-10 w-10 text-blue-400" />;
if (mimeType?.startsWith("image/")) return <Image className="h-10 w-10 text-green-400" />;
return <FileText className="h-10 w-10 text-orange-400" />;
}
function FieldError({ message }) {
if (!message) return null;
return <p className="text-xs text-destructive mt-1">{message}</p>;
}
function formatBytes(bytes) {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
// ─── Thumbnail Drop Zone ──────────────────────────────────────────────────────
function ThumbnailDropZone({ currentUrl, newFile, onFile, onClear, error }) {
console.log(currentUrl)
const inputRef = useRef(null);
const preview = newFile
? URL.createObjectURL(newFile)
: currentUrl ?? null;
return (
<div
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) onFile(f); }}
onClick={() => !newFile && inputRef.current?.click()}
className={[
"relative rounded-lg border-2 border-dashed transition-colors overflow-hidden",
error ? "border-destructive/60 bg-destructive/5" : "",
newFile ? "border-border cursor-default" : "",
!newFile && !error ? "border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 cursor-pointer" : "",
].join(" ")}
>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => { if (e.target.files[0]) onFile(e.target.files[0]); }}
/>
{preview ? (
<div className="relative">
<img
src={preview}
alt="Thumbnail preview"
className="w-full object-contain"
/>
{/* overlay actions */}
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button
type="button"
size="sm"
variant="secondary"
onClick={(e) => { e.stopPropagation(); inputRef.current?.click(); }}
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Replace
</Button>
{newFile && (
<Button
type="button"
size="sm"
variant="destructive"
onClick={(e) => { e.stopPropagation(); onClear(); }}
>
<X className="h-3.5 w-3.5 mr-1.5" />
Remove
</Button>
)}
</div>
{newFile && (
<Badge className="absolute top-2 right-2 bg-primary text-primary-foreground text-xs">
New
</Badge>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center gap-2 py-10 px-4">
<UploadCloud className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground text-center">
Drag & drop or <span className="text-primary font-medium">browse</span> to upload
<br />
<span className="text-xs">JPEG, PNG</span>
</p>
</div>
)}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function EditAsset() {
const navigate = useNavigate();
const { assetId } = useParams();
const { fetchAsset, updateAsset, loading } = useAssets();
const { user } = useAuth();
const thumbnailRef = useRef(null);
const [thumbKey, setThumbKey] = useState(0);
const [initializing, setInitializing] = useState(true);
const [asset, setAsset] = useState(null); // ← local, always fresh
const {
register,
control,
handleSubmit,
reset,
formState: { errors, isDirty },
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
display_name: "",
description: "",
is_public: "false",
},
});
useEffect(() => {
(async () => {
const res = await fetchAsset(assetId);
const fetched = res?.data?.data ?? null;
if (!fetched) return;
setAsset(fetched);
reset({
display_name: fetched.display_name ?? "",
description: fetched.description ?? "",
is_public: fetched.is_public ? "true" : "false",
});
setInitializing(false);
})();
}, [assetId]);
const isVideo = asset?.file_type === "video";
const hasThumbnailChange = !!thumbnailRef.current;
const onSubmit = async (data) => {
const result = await updateAsset(
assetId,
{
display_name: data.display_name,
description: data.description ?? "",
is_public: data.is_public === "true",
updatedBy: user?.user_id,
...(isVideo && hasThumbnailChange ? { is_thumbnail: true } : {}),
},
hasThumbnailChange ? thumbnailRef.current : null,
);
if (!result) return;
navigate(-1);
};
if (initializing) {
return (
<div className="flex items-center justify-center h-64">
<Spinner className="h-6 w-6" />
</div>
);
}
if (!asset) {
return (
<div className="max-w-2xl mx-auto px-4 py-6">
<p className="text-sm text-muted-foreground">Asset not found.</p>
</div>
);
}
return (
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Edit Asset</h1>
<p className="text-sm text-muted-foreground truncate max-w-sm">
{asset.original_name}
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* ── Current file info (read-only) ── */}
<div className="rounded-lg border bg-muted/30 p-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
File Info
</p>
<div className="flex items-center gap-3">
<FileTypeIcon mimeType={asset.mime_type} />
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm font-medium truncate">{asset.original_name}</p>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="secondary" className="text-xs">{asset.file_type}</Badge>
<Badge variant="outline" className="text-xs">{asset.extension?.toUpperCase()}</Badge>
<span className="text-xs text-muted-foreground">{formatBytes(asset.file_size)}</span>
{asset.resolution && (
<span className="text-xs text-muted-foreground">{asset.resolution}</span>
)}
</div>
</div>
</div>
</div>
{/* ── Editable fields ── */}
<div className="rounded-lg border bg-card p-6 space-y-5">
{/* ── Thumbnail (video or image) ── */}
{(isVideo || asset.file_type === "image" || asset.file_type === "avatar") && (
<div className="space-y-1.5">
<Label>
Thumbnail
{isVideo && <span className="text-muted-foreground text-xs ml-1">(optional replacement)</span>}
</Label>
<ThumbnailDropZone
key={thumbKey}
currentUrl={asset.file_type === "video" ? asset.thumbnail_url : asset.file_url}
newFile={thumbnailRef.current}
onFile={(f) => {
thumbnailRef.current = f;
setThumbKey((k) => k + 1);
}}
onClear={() => {
thumbnailRef.current = null;
setThumbKey((k) => k + 1);
}}
error={null}
/>
</div>
)}
{/* ── Display Name ── */}
<div className="space-y-1.5">
<Label htmlFor="display_name">
Display Name <span className="text-destructive">*</span>
</Label>
<Input
id="display_name"
placeholder="Friendly name for this asset"
{...register("display_name")}
/>
<FieldError message={errors.display_name?.message} />
</div>
{/* ── Description ── */}
<div className="space-y-1.5">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Optional description"
rows={3}
{...register("description")}
/>
</div>
{/* ── Access ── */}
<div className="space-y-1.5 max-w-[200px]">
<Label>Access</Label>
<Controller
name="is_public"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="false">Private</SelectItem>
<SelectItem value="true">Public</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</div>
{/* ── Actions ── */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate(-1)}
disabled={loading}
>
Cancel
</Button>
<Button
type="submit"
disabled={loading || (!isDirty && !hasThumbnailChange)}
>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Save Changes
</Button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,133 @@
// modules/admin/pages/assets/ViewDocumentAsset.jsx
import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, FileText } from "lucide-react";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
function MetaRow({ label, value }) {
if (!value && value !== 0) return null;
return (
<div className="flex items-start gap-3 py-2">
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
<span className="text-sm font-medium break-all">{String(value)}</span>
</div>
);
}
const PREVIEWABLE = ["pdf", "txt", "html", "htm", "csv", "md"];
export default function ViewDocumentAsset() {
const { assetId } = useParams();
const navigate = useNavigate();
const { selectedAsset, loading, fetchAsset } = useAssets();
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Spinner className="size-8" />
</div>
);
}
if (!selectedAsset) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
</div>
);
}
const a = selectedAsset;
const canPreview = PREVIEWABLE.includes((a.extension ?? "").toLowerCase());
return (
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* ── Document preview ── */}
<div className="lg:col-span-3">
{canPreview && a.file_url ? (
<div className="rounded-lg border overflow-hidden bg-white" style={{ height: 600 }}>
<iframe
src={a.file_url}
title={a.display_name ?? a.original_name}
className="w-full h-full"
// sandbox="allow-scripts allow-same-origin"
/>
</div>
) : (
<div className="rounded-lg border bg-muted/30 flex flex-col items-center justify-center gap-4 py-20">
<FileText className="h-16 w-16 text-muted-foreground/40" />
<p className="text-muted-foreground text-sm">
Preview not available for this file type.
</p>
</div>
)}
</div>
{/* ── Metadata panel ── */}
<div className="lg:col-span-2 space-y-4">
<div className="rounded-lg border bg-card p-4 space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
<MetaRow label="Original Name" value={a.original_name} />
<MetaRow label="Extension" value={a.extension} />
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
<MetaRow label="MIME Type" value={a.mime_type} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
<MetaRow label="Provider" value={a.storage_provider} />
<MetaRow label="Bucket" value={a.storage_bucket} />
<MetaRow label="Key" value={a.storage_key} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
<div className="flex items-center gap-2 py-1">
{a.is_public
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
}
</div>
<MetaRow label="Access Level" value={a.access_level} />
<MetaRow label="Owner Type" value={a.owner_type} />
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
</div>
{a.description && (
<div className="rounded-lg border bg-card p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
<p className="text-sm text-foreground">{a.description}</p>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,125 @@
// modules/admin/pages/assets/ViewImageAsset.jsx
import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
function MetaRow({ label, value }) {
if (!value && value !== 0) return null;
return (
<div className="flex items-start gap-3 py-2">
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
<span className="text-sm font-medium break-all">{String(value)}</span>
</div>
);
}
export default function ViewImageAsset() {
const { assetId } = useParams();
const navigate = useNavigate();
const { selectedAsset, loading, fetchAsset } = useAssets();
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Spinner className="size-8" />
</div>
);
}
if (!selectedAsset) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
</div>
);
}
const a = selectedAsset;
return (
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* ── Image preview ── */}
<div className="lg:col-span-3 rounded-lg border bg-muted/30 overflow-hidden flex items-center justify-center min-h-64">
{a.file_url ? (
<img
src={a.file_url}
alt={a.display_name ?? a.original_name}
className="max-w-full max-h-[520px] object-contain"
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
) : (
<p className="text-muted-foreground text-sm">No preview available.</p>
)}
</div>
{/* ── Metadata panel ── */}
<div className="lg:col-span-2 space-y-4">
<div className="rounded-lg border bg-card p-4 space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
<MetaRow label="Original Name" value={a.original_name} />
<MetaRow label="Extension" value={a.extension} />
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
<MetaRow label="Resolution" value={a.resolution} />
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
<MetaRow label="Provider" value={a.storage_provider} />
<MetaRow label="Bucket" value={a.storage_bucket} />
<MetaRow label="Key" value={a.storage_key} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
<div className="flex items-center gap-2 py-1">
{a.is_public
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
}
</div>
<MetaRow label="Access Level" value={a.access_level} />
<MetaRow label="Owner Type" value={a.owner_type} />
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
</div>
{a.description && (
<div className="rounded-lg border bg-card p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
<p className="text-sm text-foreground">{a.description}</p>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,161 @@
// modules/admin/pages/assets/ViewVideoAsset.jsx
import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
function MetaRow({ label, value }) {
if (!value && value !== 0) return null;
return (
<div className="flex items-start gap-3 py-2">
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
<span className="text-sm font-medium break-all">{String(value)}</span>
</div>
);
}
function formatDuration(seconds) {
if (!seconds && seconds !== 0) return null;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return [h > 0 ? String(h).padStart(2, "0") : null, String(m).padStart(2, "0"), String(s).padStart(2, "0")]
.filter(Boolean)
.join(":");
}
export default function ViewVideoAsset() {
const { assetId } = useParams();
const navigate = useNavigate();
const { selectedAsset, loading, fetchAsset } = useAssets();
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Spinner className="size-8" />
</div>
);
}
if (!selectedAsset) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
</div>
);
}
const a = selectedAsset;
return (
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
{/* ── Header ── */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* ── Video player ── */}
<div className="lg:col-span-3 space-y-3">
<div className="rounded-lg border bg-black overflow-hidden aspect-video flex items-center justify-center">
{a.file_url ? (
<video
key={a.file_url}
controls
controlsList="nodownload" // ← hides download button in browser controls
disablePictureInPicture // ← hides PiP button
onContextMenu={(e) => e.preventDefault()} // ← disables right-click save
className="w-full h-full"
poster={a.thumbnail_url ?? undefined}
>
<source src={a.file_url} type={a.mime_type ?? "video/mp4"} />
Your browser does not support the video tag.
</video>
) : (
<p className="text-muted-foreground text-sm">No video source available.</p>
)}
</div>
{/* Thumbnail strip */}
{a.thumbnail_url && (
<div className="rounded-lg border overflow-hidden bg-muted/30">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground px-3 py-2">Thumbnail</p>
<img
src={a.thumbnail_url}
alt="Thumbnail"
className="w-full max-h-40 object-cover"
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
)}
</div>
{/* ── Metadata panel ── */}
<div className="lg:col-span-2 space-y-4">
<div className="rounded-lg border bg-card p-4 space-y-1">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Video Info</p>
<MetaRow label="Original Name" value={a.original_name} />
<MetaRow label="Extension" value={a.extension} />
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / (1024 * 1024)).toFixed(2)} MB` : null} />
<MetaRow label="Resolution" value={a.resolution} />
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
<MetaRow label="Duration" value={formatDuration(a.duration)} />
<MetaRow label="Frame Rate" value={a.frame_rate ? `${a.frame_rate} fps` : null} />
<MetaRow label="Bitrate" value={a.bitrate ? `${a.bitrate} kbps` : null} />
<MetaRow label="Video Codec" value={a.video_codec} />
<MetaRow label="Audio Codec" value={a.audio_codec} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
<MetaRow label="Provider" value={a.storage_provider} />
<MetaRow label="Bucket" value={a.storage_bucket} />
<MetaRow label="Key" value={a.storage_key} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
<div className="flex items-center gap-2 py-1">
{a.is_public
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
}
</div>
<MetaRow label="Access Level" value={a.access_level} />
<MetaRow label="Owner Type" value={a.owner_type} />
<MetaRow label="Owner ID" value={a.owner_id} />
<Separator className="my-2" />
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
<MetaRow label="Created By" value={a.createdBy} />
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
</div>
{a.description && (
<div className="rounded-lg border bg-card p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
<p className="text-sm text-foreground">{a.description}</p>
</div>
)}
</div>
</div>
</div>
);
}
@@ -5,8 +5,8 @@ import ArchiveGroupTable from "../../components/user_groups/ArchiveGroupTable";
export default function ArchivedGroupList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
{ label: "User Group", to: `/admin/users/groups` },
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "User Group", to: `/admin/groups` },
{ label: "Archived" },
]
@@ -5,7 +5,7 @@ import GroupTable from "../../components/user_groups/GroupTable";
export default function GroupList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "User Groups" },
]
@@ -100,8 +100,8 @@ export default function ViewGroup() {
};
const breadcrumbItems = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin/users" },
{ label: "User Groups", to: "/admin/users/groups" },
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "User Groups", to: "/admin/groups" },
{ label: group?.name ?? "View Group" },
];
@@ -5,8 +5,8 @@ import ArchiveUserTable from "../../components/users/ArchiveUserTable";
export default function ArchivedUserList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
{ label: "Users", to: `/admin/users/all` },
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "Users", to: `/admin/users` },
{ label: "Archived" },
]
+1 -1
View File
@@ -172,7 +172,7 @@ export default function EditUser() {
{/* ─── Header ──────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/users/all`)}>
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/users`)}>
<ArrowLeft className="size-4" />
</Button>
<div>
@@ -1,6 +0,0 @@
import { USER_MANAGEMENT } from "@/data/adminTiles.data";
import DashboardGrid from "@/components/generic/DashboardGrid";
export default function UserDashboard() {
return <DashboardGrid sections={USER_MANAGEMENT} />
}
+1 -1
View File
@@ -5,7 +5,7 @@ import UsersTable from "../../components/users/UserTable";
export default function UserList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "Users" },
]
+1 -1
View File
@@ -48,7 +48,7 @@ export default function ViewUser() {
{/* ─── Header ──────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users/all`)}>
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users`)}>
<ArrowLeft className="size-4" />
</Button>
<div>