assets and tier plans revamp

This commit is contained in:
rgrgogu
2026-08-01 17:44:12 +08:00
parent 4f738a691d
commit a34c6feb84
45 changed files with 2172 additions and 273 deletions
+5 -4
View File
@@ -9,7 +9,8 @@ import { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, File
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAuth } from "@/contexts/AuthContext";
import { MAX_ASSET_FILE_SIZE, MAX_ASSET_FILE_SIZE_LABEL } from "@/utils/assetUpload.util";
import { MAX_ASSET_FILE_SIZE_SINGLE, MAX_ASSET_FILE_SIZE_SINGLE_LABEL } from "@/utils/assetUpload.util";
import { formatFileSize } from "@/utils/format.util";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -83,7 +84,7 @@ function DropZone({ label, accept, file, onFile, onClear, error }) {
<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}
{formatFileSize(file.size)} · {file.type}
</p>
</div>
<Button
@@ -157,8 +158,8 @@ export default function AddAsset() {
const fileType = file ? resolveFileType(file.type) : null;
const setFile = (f) => {
if (f.size > MAX_ASSET_FILE_SIZE) {
setError("_file", { message: `File exceeds the ${MAX_ASSET_FILE_SIZE_LABEL} size limit.` });
if (f.size > MAX_ASSET_FILE_SIZE_SINGLE) {
setError("_file", { message: `File exceeds the ${MAX_ASSET_FILE_SIZE_SINGLE_LABEL} size limit.` });
return;
}
fileRef.current = f;
@@ -10,6 +10,7 @@ import {
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useUploadQueue } from "@/contexts/UploadQueueContext";
import { useAuth } from "@/contexts/AuthContext";
import { formatFileSize } from "@/utils/format.util";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
@@ -29,12 +30,6 @@ function FileTypeIcon({ mime = "" }) {
return <FileText className="h-8 w-8 text-orange-400 shrink-0" />;
}
function formatSize(bytes = 0) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ─── Drop zone ────────────────────────────────────────────────────────────────
function DropZone({ onFiles }) {
@@ -93,7 +88,7 @@ function FileRow({ job, onRetry, onRemove }) {
<div className="flex-1 min-w-0 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium truncate">{job.name}</p>
<span className="text-xs text-muted-foreground shrink-0">{formatSize(job.size)}</span>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(job.size)}</span>
</div>
<Progress value={job.progress} className={barClass} />
<div className="flex items-center gap-1.5 text-xs">
+2 -7
View File
@@ -9,6 +9,7 @@ import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, RefreshCw } from
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { formatFileSize } from "@/utils/format.util";
import { useAuth } from "@/contexts/AuthContext";
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
import { Button } from "@/components/ui/button";
@@ -46,12 +47,6 @@ function FieldError({ message }) {
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 ──────────────────────────────────────────────────────
@@ -249,7 +244,7 @@ export default function EditAsset() {
<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>
<span className="text-xs text-muted-foreground">{formatFileSize(asset.file_size) ?? "—"}</span>
{asset.resolution && (
<span className="text-xs text-muted-foreground">{asset.resolution}</span>
)}
@@ -1,12 +1,12 @@
// modules/admin/pages/assets/ViewAudioAsset.jsx
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Music2, Download } from "lucide-react";
import { ArrowLeft, Lock, Globe, Music2 } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util";
import { formatFileSize } from "@/utils/format.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -71,9 +71,6 @@ export default function ViewAudioAsset() {
<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>
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
<Download className="h-4 w-4" /> Download
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
@@ -93,7 +90,7 @@ export default function ViewAudioAsset() {
<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="File Size" value={formatFileSize(a.file_size)} />
<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>
@@ -1,17 +1,16 @@
// modules/admin/pages/assets/ViewDocumentAsset.jsx
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, FileText, Download } from "lucide-react";
import { ArrowLeft, Lock, Globe, FileText } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util";
import { Spinner } from "@/components/ui/spinner";
import { formatFileSize } from "@/utils/format.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MediaFallback } from "@/components/generic/MediaFallback";
import FileZoomViewer from "@/components/generic/FileZoomViewer";
import AssetPageLoader from "@/components/generic/AssetLoader";
function MetaRow({ label, value }) {
@@ -32,7 +31,7 @@ export default function ViewDocumentAsset() {
const { fmtDateTime } = useDateFormat();
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
if (loading) {
return <AssetPageLoader />;
@@ -48,7 +47,9 @@ export default function ViewDocumentAsset() {
}
const a = selectedAsset;
const canPreview = PREVIEWABLE.includes((a.extension ?? "").toLowerCase());
const ext = (a.extension ?? "").toLowerCase();
const canPreview = PREVIEWABLE.includes(ext);
const isPdf = ext === "pdf";
return (
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
@@ -62,16 +63,22 @@ export default function ViewDocumentAsset() {
<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>
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
<Download className="h-4 w-4" /> Download
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
{/* ── Document preview ── */}
<div className="lg:col-span-3">
{canPreview && streamUrl ? (
{isPdf ? (
<div className="rounded-lg border overflow-hidden">
<FileZoomViewer
src={streamUrl}
mimeType={a.mime_type}
fileName={a.display_name ?? a.original_name}
loading={previewLoading}
/>
</div>
) : canPreview && streamUrl ? (
<div className="rounded-lg border overflow-hidden bg-white" style={{ height: 600 }}>
<iframe
src={streamUrl}
@@ -96,7 +103,7 @@ export default function ViewDocumentAsset() {
<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="File Size" value={formatFileSize(a.file_size)} />
<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>
@@ -1,16 +1,16 @@
// modules/admin/pages/assets/ViewImageAsset.jsx
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util";
import { formatFileSize } from "@/utils/format.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MediaFallback } from "@/components/generic/MediaFallback";
import FileZoomViewer from "@/components/generic/FileZoomViewer";
import AssetPageLoader from "@/components/generic/AssetLoader";
@@ -30,7 +30,7 @@ export default function ViewImageAsset() {
const { fmtDateTime } = useDateFormat();
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
const { src: streamUrl, loading: previewLoading } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
if (loading) {
return <AssetPageLoader />;
@@ -59,26 +59,18 @@ export default function ViewImageAsset() {
<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>
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
<Download className="h-4 w-4" /> Download
</Button>
</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">
{streamUrl ? (
<img
src={streamUrl}
alt={a.display_name ?? a.original_name}
className="max-w-full max-h-[520px] object-contain"
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
) : (
<MediaFallback className="size-full" />
)}
<div className="lg:col-span-3 rounded-lg border overflow-hidden">
<FileZoomViewer
src={streamUrl}
mimeType={a.mime_type}
fileName={a.display_name ?? a.original_name}
loading={previewLoading}
/>
</div>
{/* ── Metadata panel ── */}
@@ -87,7 +79,7 @@ export default function ViewImageAsset() {
<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="File Size" value={formatFileSize(a.file_size)} />
<MetaRow label="Resolution" value={a.resolution} />
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
<Separator className="my-2" />
@@ -1,16 +1,16 @@
// modules/admin/pages/assets/ViewVideoAsset.jsx
import { useParams, useNavigate } from "react-router-dom";
import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
import { ArrowLeft, Lock, Globe } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssetFetchState } from "@/hooks/useAssetFetchState";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { downloadAsset } from "@/utils/media.util";
import { formatFileSize } from "@/utils/format.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { VideoBlock } from "@/components/generic/Blocks/Admin/VideoBlock";
import { TranscodeStatusBanner } from "@/components/generic/TranscodeStatusBanner";
import AssetPageLoader from "@/components/generic/AssetLoader";
function MetaRow({ label, value }) {
@@ -39,7 +39,6 @@ export default function ViewVideoAsset() {
const { fmtDateTime } = useDateFormat();
const { asset: selectedAsset, loading, notFound } = useAssetFetchState(assetId);
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
if (loading) {
return <AssetPageLoader />;
@@ -68,33 +67,25 @@ export default function ViewVideoAsset() {
<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>
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
<Download className="h-4 w-4" /> Download
</Button>
</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">
{streamUrl ? (
<video
key={streamUrl}
controls
controlsList="nodownload"
disablePictureInPicture
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full"
poster={a.thumbnail_url ?? undefined}
>
<source src={streamUrl} type={a.mime_type ?? "video/mp4"} />
Your browser does not support the video tag.
</video>
) : (
<MediaFallback className="size-full" />
)}
</div>
<TranscodeStatusBanner status={a.transcode_status} />
<VideoBlock
readOnly
onUpdate={() => {}}
content={{
asset_id: a.asset_id,
storage_provider: a.storage_provider,
url: a.file_url,
thumbnail_url: a.thumbnail_url,
title: a.display_name ?? a.original_name,
tag: a.extension?.toUpperCase() ?? "",
}}
/>
{/* Thumbnail strip */}
{a.thumbnail_url && (
@@ -117,7 +108,7 @@ export default function ViewVideoAsset() {
<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="File Size" value={formatFileSize(a.file_size)} />
<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)} />