mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user