This commit is contained in:
rgrgogu
2026-07-20 08:17:38 +08:00
parent cab0883512
commit d021725c1b
8 changed files with 149 additions and 168 deletions
@@ -2,7 +2,7 @@
// Column definitions and pinning for the standalone Lesson Library table. // Column definitions and pinning for the standalone Lesson Library table.
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Clock } from "lucide-react"; import { Clock, Layers } from "lucide-react";
import { buildColumns } from "@/utils/table.util"; import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
@@ -13,13 +13,23 @@ export const columnPinning = {
left: [], left: [],
}; };
const COURSE_STATUS_BADGE = {
published: "default",
draft: "secondary",
standalone: "outline",
};
const cellOverrides = { const cellOverrides = {
title: (info) => {
const inUnit = parseInt(info.row.original.unit_count ?? 0, 10) > 0;
return (
<div className="flex items-center gap-1.5 min-w-0">
{inUnit && (
<Badge variant="secondary" className="gap-1 text-[10px] font-medium shrink-0">
<Layers className="h-3 w-3" />
Unit
</Badge>
)}
<span className="block truncate max-w-56 text-sm" title={info.getValue()}>
{info.getValue()}
</span>
</div>
);
},
duration_seconds: (info) => { duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10); const seconds = parseInt(info.getValue() ?? 0, 10);
return ( return (
@@ -39,11 +49,6 @@ const cellOverrides = {
</Badge> </Badge>
); );
}, },
course_status: (info) => (
<Badge variant={COURSE_STATUS_BADGE[info.getValue()] ?? "outline"} className="text-xs capitalize">
{info.getValue()}
</Badge>
),
}; };
export function buildDataColumns(attributes, rowActions) { export function buildDataColumns(attributes, rowActions) {
@@ -2,7 +2,7 @@
// Column definitions and pinning for the standalone Unit Library table. // Column definitions and pinning for the standalone Unit Library table.
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Clock } from "lucide-react"; import { Clock, GraduationCap } from "lucide-react";
import { buildColumns } from "@/utils/table.util"; import { buildColumns } from "@/utils/table.util";
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn"; import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn"; import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
@@ -13,13 +13,23 @@ export const columnPinning = {
left: [], left: [],
}; };
const COURSE_STATUS_BADGE = {
published: "default",
draft: "secondary",
standalone: "outline",
};
const cellOverrides = { const cellOverrides = {
title: (info) => {
const inCourse = parseInt(info.row.original.course_count ?? 0, 10) > 0;
return (
<div className="flex items-center gap-1.5 min-w-0">
{inCourse && (
<Badge variant="secondary" className="gap-1 text-[10px] font-medium shrink-0">
<GraduationCap className="h-3 w-3" />
Course
</Badge>
)}
<span className="block truncate max-w-56 text-sm" title={info.getValue()}>
{info.getValue()}
</span>
</div>
);
},
duration_seconds: (info) => { duration_seconds: (info) => {
const seconds = parseInt(info.getValue() ?? 0, 10); const seconds = parseInt(info.getValue() ?? 0, 10);
return ( return (
@@ -36,19 +46,6 @@ const cellOverrides = {
{info.getValue() ?? 0} lesson{(info.getValue() ?? 0) === 1 ? "" : "s"} {info.getValue() ?? 0} lesson{(info.getValue() ?? 0) === 1 ? "" : "s"}
</Badge> </Badge>
), ),
course_count: (info) => {
const n = parseInt(info.getValue() ?? 0, 10);
return (
<Badge variant="secondary" className="text-xs tabular-nums">
{n} course{n === 1 ? "" : "s"}
</Badge>
);
},
course_status: (info) => (
<Badge variant={COURSE_STATUS_BADGE[info.getValue()] ?? "outline"} className="text-xs capitalize">
{info.getValue()}
</Badge>
),
}; };
export function buildDataColumns(attributes, rowActions) { export function buildDataColumns(attributes, rowActions) {
@@ -14,12 +14,6 @@ export function buildRowActions({ onView, onEdit, onManageLessons, onArchive, on
icon: <Eye className="h-3.5 w-3.5" />, icon: <Eye className="h-3.5 w-3.5" />,
onClick: (row) => onView(row), onClick: (row) => onView(row),
}, },
{
key: "edit",
label: "Edit Unit",
icon: <Pencil className="h-3.5 w-3.5" />,
onClick: (row) => onEdit(row),
},
{ {
key: "manage_lessons", key: "manage_lessons",
label: "Manage Lessons", label: "Manage Lessons",
+11 -26
View File
@@ -185,12 +185,6 @@ export default function AddAsset() {
hasFileError = true; 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;
}
if (hasFileError) return; if (hasFileError) return;
setProgress({ phase: "uploading", pct: 0 }); setProgress({ phase: "uploading", pct: 0 });
@@ -259,15 +253,14 @@ export default function AddAsset() {
</Link> </Link>
</div> </div>
{/* ── Thumbnail (video required / audio optional) ── */} {/* ── Thumbnail (optional for both video and audio) ── */}
{(isVideo || isAudio) && ( {(isVideo || isAudio) && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label> <Label>
Thumbnail{" "} Thumbnail{" "}
{isVideo <span className="text-muted-foreground text-xs">
? <span className="text-destructive">*</span> (optional — {isVideo ? "video preview" : "album / cover art"})
: <span className="text-muted-foreground text-xs">(optional — album / cover art)</span> </span>
}
</Label> </Label>
<DropZone <DropZone
key={thumbKey} key={thumbKey}
@@ -350,23 +343,15 @@ export default function AddAsset() {
/> />
</div> </div>
{/* Storage Provider */} {/* Storage Provider — TEMPORARY: locked to S3 (see zrok tunnel note above; revert after VPS migration) */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Storage</Label> <Label>Storage</Label>
<Controller <Select value="s3" disabled>
name="storage_provider" <SelectTrigger className="disabled:opacity-60 disabled:cursor-not-allowed"><SelectValue /></SelectTrigger>
control={control} <SelectContent>
render={({ field }) => ( <SelectItem value="s3">S3</SelectItem>
<Select value={field.value} onValueChange={field.onChange}> </SelectContent>
<SelectTrigger><SelectValue /></SelectTrigger> </Select>
<SelectContent>
<SelectItem value="chibisafe">Chibisafe</SelectItem>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem>
</SelectContent>
</Select>
)}
/>
</div> </div>
</div> </div>
@@ -131,7 +131,8 @@ export default function AddAssetsBulk() {
const { user } = useAuth(); const { user } = useAuth();
const [isPublic, setIsPublic] = useState("false"); const [isPublic, setIsPublic] = useState("false");
const [storageProvider, setStorageProvider] = useState("s3"); // TEMPORARY: locked to S3 (see zrok tunnel note; revert after VPS migration)
const storageProvider = "s3";
const total = jobs.length; const total = jobs.length;
const done = jobs.filter((j) => j.status === "uploaded").length; const done = jobs.filter((j) => j.status === "uploaded").length;
@@ -177,11 +178,10 @@ export default function AddAssetsBulk() {
</div> </div>
<div className="flex-1 space-y-1.5"> <div className="flex-1 space-y-1.5">
<label className="text-sm font-medium">Storage</label> <label className="text-sm font-medium">Storage</label>
<Select value={storageProvider} onValueChange={setStorageProvider}> {/* TEMPORARY: locked to S3 (see zrok tunnel note; revert after VPS migration) */}
<SelectTrigger><SelectValue /></SelectTrigger> <Select value={storageProvider} disabled>
<SelectTrigger className="disabled:opacity-60 disabled:cursor-not-allowed"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="chibisafe">Chibisafe</SelectItem>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="s3">S3</SelectItem> <SelectItem value="s3">S3</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -113,7 +113,7 @@ function InviteLinkDialog({ open, onOpenChange, group }) {
{/* Invite link */} {/* Invite link */}
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-lg border bg-muted px-3 py-2 min-w-0"> <div className="flex items-center gap-2 rounded-lg border bg-muted px-3 py-2 min-w-0">
<p className="font-mono text-xs text-muted-foreground truncate flex-1"> <p className="font-mono text-xs text-muted-foreground truncate w-64">
{inviteUrl} {inviteUrl}
</p> </p>
</div> </div>
+97 -97
View File
@@ -62,9 +62,9 @@ function ReferDialog({ open, onOpenChange }) {
const handleDownload = () => { const handleDownload = () => {
const canvas = document.querySelector("[data-qr='refer-invite']") const canvas = document.querySelector("[data-qr='refer-invite']")
if (!canvas) return if (!canvas) return
const url = canvas.toDataURL("image/png") const url = canvas.toDataURL("image/png")
const link = document.createElement("a") const link = document.createElement("a")
link.href = url link.href = url
link.download = `QR_${group.group_code}.png` link.download = `QR_${group.group_code}.png`
link.click() link.click()
} }
@@ -112,7 +112,7 @@ function ReferDialog({ open, onOpenChange }) {
{/* Invite link */} {/* Invite link */}
<div className="w-full flex flex-col gap-2"> <div className="w-full flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-lg border bg-muted px-3 py-2 min-w-0"> <div className="flex items-center gap-2 rounded-lg border bg-muted px-3 py-2 min-w-0">
<p className="font-mono text-xs text-muted-foreground truncate flex-1"> <p className="font-mono text-xs text-muted-foreground truncate w-64">
{inviteUrl} {inviteUrl}
</p> </p>
</div> </div>
@@ -175,10 +175,10 @@ function ClientNav() {
// Derive primitive deps — effect only fires when the actual asset changes, // Derive primitive deps — effect only fires when the actual asset changes,
// not on every tierMap/tierSlug reference churn. // not on every tierMap/tierSlug reference churn.
const badgeAsset = tierMap[tierSlug]?.badgeAsset ?? null const badgeAsset = tierMap[tierSlug]?.badgeAsset ?? null
const badgeAssetId = badgeAsset?.asset_id ?? null const badgeAssetId = badgeAsset?.asset_id ?? null
const badgeProvider = badgeAsset?.storage_provider ?? null const badgeProvider = badgeAsset?.storage_provider ?? null
const badgeFileUrl = badgeAsset?.file_url ?? null const badgeFileUrl = badgeAsset?.file_url ?? null
useEffect(() => { useEffect(() => {
if (!badgeAssetId) { setBadgeImgUrl(null); return } if (!badgeAssetId) { setBadgeImgUrl(null); return }
@@ -220,83 +220,83 @@ function ClientNav() {
return ( return (
<> <>
<nav className="bg-card w-full border-b border-default"> <nav className="bg-card w-full border-b border-default">
<div className="flex flex-wrap items-center justify-between mx-auto py-3 px-6"> <div className="flex flex-wrap items-center justify-between mx-auto py-3 px-6">
{/* Logo */} {/* Logo */}
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<div className="w-40 cursor-pointer" onClick={() => navigate("/")}> <div className="w-40 cursor-pointer" onClick={() => navigate("/")}>
<img src="/philpro-white.png" alt="Philproperties" className="object-cover dark:hidden" /> <img src="/philpro-white.png" alt="Philproperties" className="object-cover dark:hidden" />
<img src="/philpro-dark.png" alt="Philproperties" className="object-cover hidden dark:block" /> <img src="/philpro-dark.png" alt="Philproperties" className="object-cover hidden dark:block" />
</div> </div>
<div> <div>
<svg <svg
data-testid="geist-icon" data-testid="geist-icon"
height="16" height="16"
width="16" width="16"
viewBox="0 0 16 16" viewBox="0 0 16 16"
strokeLinejoin="round" strokeLinejoin="round"
className="xs:hidden sm:block fill-slate-300" className="xs:hidden sm:block fill-slate-300"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
fillRule="evenodd" fillRule="evenodd"
clipRule="evenodd" clipRule="evenodd"
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z" d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
/> />
</svg> </svg>
</div> </div>
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2"> <div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
{tierBadge && ( {tierBadge && (
<div className={`xs:hidden md:inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${tierBadge.cls}`}> <div className={`xs:hidden md:inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${tierBadge.cls}`}>
{badgeImgUrl {badgeImgUrl
? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" /> ? <img src={badgeImgUrl} className="size-3.5 rounded-full object-cover" />
: TierIcon && <TierIcon className="size-3" /> : TierIcon && <TierIcon className="size-3" />
} }
{tierBadge.label} {tierBadge.label}
</div>
)}
</div>
</div>
{/* Right — bell + user menu */}
<div className="flex items-center gap-3">
<ClientNotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="cursor-pointer">
<AvatarImage src={avatarUrl} />
<AvatarFallback className={`text-sm font-semibold ${avatarColor}`}>
{initials || "PH"}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* User info */}
<DropdownMenuGroup className="p-1.5">
<div className="w-full truncate text-start text-sm font-medium">
{fullName || "—"}
</div> </div>
<div className="w-full truncate text-start text-[13px] font-medium text-muted-foreground"> )}
{email} </div>
</div> </div>
</DropdownMenuGroup>
<DropdownMenuSeparator /> {/* Right — bell + user menu */}
<div className="flex items-center gap-3">
<ClientNotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="cursor-pointer">
<AvatarImage src={avatarUrl} />
<AvatarFallback className={`text-sm font-semibold ${avatarColor}`}>
{initials || "PH"}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuGroup> <DropdownMenuContent align="end" className="w-64">
<DropdownMenuItem className="bg-gradient-to-r from-[#0e7490] via-[#3b82f6] to-[#4f46e5] text-white" onClick={() => navigate("/plans")}>
<Zap /> Plans {/* User info */}
</DropdownMenuItem> <DropdownMenuGroup className="p-1.5">
<DropdownMenuItem onClick={() => navigate("/profile")}> <div className="w-full truncate text-start text-sm font-medium">
<User /> Profile {fullName || "—"}
</DropdownMenuItem> </div>
<DropdownMenuItem onClick={() => navigate("/settings")}> <div className="w-full truncate text-start text-[13px] font-medium text-muted-foreground">
<Settings /> Account Settings {email}
</DropdownMenuItem> </div>
{/* <DropdownMenuItem> </DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="bg-gradient-to-r from-[#0e7490] via-[#3b82f6] to-[#4f46e5] text-white" onClick={() => navigate("/plans")}>
<Zap /> Plans
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/profile")}>
<User /> Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/settings")}>
<Settings /> Account Settings
</DropdownMenuItem>
{/* <DropdownMenuItem>
<TableOfContents /> Documentation <TableOfContents /> Documentation
<DropdownMenuShortcut> <DropdownMenuShortcut>
<SquareArrowOutUpRight /> <SquareArrowOutUpRight />
@@ -308,29 +308,29 @@ function ClientNav() {
<DropdownMenuItem onSelect={() => setReferOpen(true)}> <DropdownMenuItem onSelect={() => setReferOpen(true)}>
<Gift /> Refer <Gift /> Refer
</DropdownMenuItem> */} </DropdownMenuItem> */}
<DropdownMenuItem onSelect={(e) => e.preventDefault()}> <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
Theme Theme
<DropdownMenuShortcut> <DropdownMenuShortcut>
<ThemeSwitcher /> <ThemeSwitcher />
</DropdownMenuShortcut> </DropdownMenuShortcut>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={handleLogout}> <DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOut /> Log out <LogOut /> Log out
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
</div> </div>
</div> </nav>
</nav>
<ReferDialog open={referOpen} onOpenChange={setReferOpen} /> <ReferDialog open={referOpen} onOpenChange={setReferOpen} />
</> </>
) )
} }
+1 -1
View File
@@ -129,7 +129,7 @@ function renderCell(attr, value) {
} }
case "date": return <span className="text-xs text-muted-foreground">{formatDate(value)}</span>; case "date": return <span className="text-xs text-muted-foreground">{formatDate(value)}</span>;
case "number": return <span className="font-mono text-xs font-semibold text-muted-foreground">{value}</span>; case "number": return <span className="font-mono text-xs font-semibold text-muted-foreground">{value}</span>;
default: return <span className="text-sm">{value}</span>; default: return <span className="block truncate max-w-56 text-sm" title={String(value)}>{value}</span>;
} }
} }