ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:07:20 +08:00
parent 56d984a26a
commit fbef7cb6e6
283 changed files with 25961 additions and 1072 deletions
+23 -12
View File
@@ -5,7 +5,7 @@ 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 { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext";
@@ -25,10 +25,11 @@ import {
// ─── Derive file_type from MIME type ──────────────────────────────────────────
function resolveFileType(mimeType = "") {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("audio/")) return "audio";
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
return "image";
return "document";
}
// ─── Schema ───────────────────────────────────────────────────────────────────
@@ -45,6 +46,7 @@ const schema = z.object({
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" />;
if (mimeType.startsWith("audio/")) return <FileAudio className="h-10 w-10 text-purple-400" />;
return <FileText className="h-10 w-10 text-orange-400" />;
}
@@ -116,7 +118,7 @@ export default function AddAsset() {
const { uploadAsset, loading } = useAssets();
const { user } = useAuth();
const fileRef = useRef(null);
const fileRef = useRef(null);
const thumbnailRef = useRef(null);
const [thumbKey, setThumbKey] = useState(0);
@@ -135,12 +137,13 @@ export default function AddAsset() {
display_name: "",
description: "",
is_public: "false",
storage_provider: "chibisafe",
storage_provider: "s3", // ← changed from "chibisafe"
},
});
const file = watch("_file");
const isVideo = file?.type?.startsWith("video/");
const isAudio = file?.type?.startsWith("audio/");
// ── Auto-derive file_type from MIME ───────────────────────────────────────
const fileType = file ? resolveFileType(file.type) : null;
@@ -166,6 +169,7 @@ export default function AddAsset() {
hasFileError = true;
}
// Thumbnail is required for video, optional for audio
if (isVideo && !thumbnailRef.current) {
setError("_thumbnail", { message: "A thumbnail is required for video uploads." });
hasFileError = true;
@@ -208,8 +212,8 @@ export default function AddAsset() {
<div className="space-y-1.5">
<Label>File <span className="text-destructive">*</span></Label>
<DropZone
label="Images, videos, documents"
accept="image/*,video/*,application/*,text/*"
label="Images, videos, audio, documents"
accept="image/*,video/*,audio/*,application/*,text/*"
file={fileRef.current}
onFile={setFile}
onClear={() => {
@@ -223,13 +227,19 @@ export default function AddAsset() {
<FieldError message={errors._file?.message} />
</div>
{/* ── Thumbnail (video only) ── */}
{isVideo && (
{/* ── Thumbnail (video required / audio optional) ── */}
{(isVideo || isAudio) && (
<div className="space-y-1.5">
<Label>Thumbnail <span className="text-destructive">*</span></Label>
<Label>
Thumbnail{" "}
{isVideo
? <span className="text-destructive">*</span>
: <span className="text-muted-foreground text-xs">(optional — album / cover art)</span>
}
</Label>
<DropZone
key={thumbKey}
label="JPEG, PNG (video thumbnail)"
label="JPEG, PNG"
accept="image/*"
file={thumbnailRef.current}
onFile={setThumbnail}
@@ -283,6 +293,7 @@ export default function AddAsset() {
<SelectItem value="avatar">Avatar</SelectItem>
<SelectItem value="image">Image</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="document">Document</SelectItem>
</SelectContent>
</Select>
@@ -0,0 +1,156 @@
// modules/admin/pages/assets/ViewAudioAsset.jsx
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Music2 } from "lucide-react";
import api from "@/utils/api.util";
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";
import { AudioBlock } from "@/components/generic/Blocks/Admin/AudioBlock";
// ─── Shared meta row (same pattern as other viewers) ─────────────────────────
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>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ViewAudioAsset() {
const { assetId } = useParams();
const navigate = useNavigate();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
const [thumbnailUrl, setThumbnailUrl] = useState(null);
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
useEffect(() => {
if (!selectedAsset) return;
setStreamUrl(null);
setThumbnailUrl(null);
if (selectedAsset.storage_provider !== "s3") {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => {
const { token, thumbnail_url } = data?.data ?? {};
if (token) setStreamUrl(`${API_BASE}/client/media/stream/${token}`);
if (thumbnail_url) setThumbnailUrl(thumbnail_url);
})
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
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 audioContent = {
url: streamUrl,
title: a.display_name ?? a.original_name,
artist: a.description ?? "",
thumbnail: thumbnailUrl ?? a.thumbnail_url ?? null,
tag: a.extension?.toUpperCase() ?? "",
};
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">
{/* ── Audio player ── */}
<div className="lg:col-span-3 space-y-4">
{streamUrl ? (
<AudioBlock content={audioContent} />
) : (
<div className="rounded-lg border bg-muted/30 flex flex-col items-center justify-center gap-4 py-20">
<Music2 className="h-16 w-16 text-muted-foreground/40" />
<p className="text-muted-foreground text-sm">Audio file URL not available.</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>
);
}
@@ -1,8 +1,9 @@
// modules/admin/pages/assets/ViewDocumentAsset.jsx
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, FileText } from "lucide-react";
import api from "@/utils/api.util";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
@@ -27,11 +28,26 @@ export default function ViewDocumentAsset() {
const navigate = useNavigate();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
useEffect(() => {
if (!selectedAsset) return;
setStreamUrl(null);
if (selectedAsset.storage_provider !== "s3") {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
@@ -70,10 +86,10 @@ export default function ViewDocumentAsset() {
{/* ── Document preview ── */}
<div className="lg:col-span-3">
{canPreview && a.file_url ? (
{canPreview && streamUrl ? (
<div className="rounded-lg border overflow-hidden bg-white" style={{ height: 600 }}>
<iframe
src={a.file_url}
src={streamUrl}
title={a.display_name ?? a.original_name}
className="w-full h-full"
// sandbox="allow-scripts allow-same-origin"
@@ -1,7 +1,8 @@
// modules/admin/pages/assets/ViewImageAsset.jsx
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "@/utils/api.util";
import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useAssets } from "@/contexts/AdminAssetsContext";
@@ -25,11 +26,26 @@ export default function ViewImageAsset() {
const navigate = useNavigate();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
useEffect(() => {
if (!selectedAsset) return;
setStreamUrl(null);
if (selectedAsset.storage_provider !== "s3") {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
@@ -67,9 +83,9 @@ export default function ViewImageAsset() {
{/* ── 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 ? (
{streamUrl ? (
<img
src={a.file_url}
src={streamUrl}
alt={a.display_name ?? a.original_name}
className="max-w-full max-h-[520px] object-contain"
draggable={false}
@@ -1,8 +1,9 @@
// modules/admin/pages/assets/ViewVideoAsset.jsx
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe } from "lucide-react";
import api from "@/utils/api.util";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { Spinner } from "@/components/ui/spinner";
@@ -35,11 +36,26 @@ export default function ViewVideoAsset() {
const navigate = useNavigate();
const { selectedAsset, loading, fetchAsset } = useAssets();
const [streamUrl, setStreamUrl] = useState(null);
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
useEffect(() => {
if (!selectedAsset) return;
setStreamUrl(null);
if (selectedAsset.storage_provider !== "s3") {
setStreamUrl(selectedAsset.file_url ?? null);
return;
}
api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
.then(({ data }) => setStreamUrl(
`${(import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "")}/client/media/stream/${data?.data?.token}`
))
.catch(() => setStreamUrl(null));
}, [selectedAsset]);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
@@ -78,17 +94,17 @@ export default function ViewVideoAsset() {
{/* ── 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 ? (
{streamUrl ? (
<video
key={a.file_url}
key={streamUrl}
controls
controlsList="nodownload" // ← hides download button in browser controls
disablePictureInPicture // ← hides PiP button
onContextMenu={(e) => e.preventDefault()} // ← disables right-click save
controlsList="nodownload"
disablePictureInPicture
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full"
poster={a.thumbnail_url ?? undefined}
>
<source src={a.file_url} type={a.mime_type ?? "video/mp4"} />
<source src={streamUrl} type={a.mime_type ?? "video/mp4"} />
Your browser does not support the video tag.
</video>
) : (