change things

This commit is contained in:
rgrgogu
2026-08-09 17:33:21 +08:00
parent 08a0d122ea
commit 377a4d4b5e
62 changed files with 424 additions and 299 deletions
+3 -3
View File
@@ -74,7 +74,7 @@ function AssetCard({ asset, streamSrc, selected, onSelect }) {
function EmptyState({ fileType }) {
return (
<div className="flex flex-col items-center justify-center h-48 gap-2">
<p className="text-sm text-muted-foreground">No {fileType} assets found.</p>
<p className="text-sm text-muted-foreground">No {fileType} files found.</p>
</div>
);
}
@@ -210,7 +210,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
const label = fileType
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
: "Assets";
: "Files";
const hasActiveFilters = activeExts.size > 0;
@@ -221,7 +221,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
{/* ── Header ── */}
<SheetHeader className="px-6 pt-6 pb-4 border-b">
<SheetTitle>Select {label}</SheetTitle>
<SheetDescription>Click an asset to attach it.</SheetDescription>
<SheetDescription>Click a file to attach it.</SheetDescription>
</SheetHeader>
{/* ── Search + Filter ── */}
@@ -28,7 +28,7 @@ const TARGET_CONFIGS = {
fetch: () => api.get("/admin/tiers", { params: { limit: 100 } }).then((res) => res.data?.data?.data ?? []),
idKey: "plan_id",
labelKey: "label",
placeholder: "Select a tier plan…",
placeholder: "Select a subscription plan…",
},
};
+2 -2
View File
@@ -12,12 +12,12 @@ export default function DashboardGrid({ sections = [] }) {
return (
<section className="bg-muted/60">
<div className="lg:container lg:mx-auto flex flex-col items-start gap-8 px-4">
<div className="lg:container lg:mx-auto flex flex-col items-start gap-8 p-4">
{sections.map(({ title, description, tiles }) => (
<div key={title} className="flex flex-col items-start gap-4 w-full">
{/* Header */}
<div className="flex flex-col gap-2 mt-6">
<div className="flex flex-col gap-2">
<h1 className="text-2xl leading-tighter font-medium tracking-tighter">
{title}
</h1>
+12 -5
View File
@@ -1,9 +1,15 @@
// components/generic/UploadProgressToast.jsx
//
// Floating widget mounted once in AdminLayout (outside the router Outlet's
// unmount cycle) so it keeps showing asset-upload progress no matter what
// admin page you navigate to mid-upload. State comes from UploadQueueContext,
// which lives above the router for the same reason.
// unmount cycle) so it keeps showing Add Assets Bulk's upload progress no
// matter what admin page you navigate to mid-batch. State comes from
// UploadQueueContext, which lives above the router for the same reason.
//
// Only renders "bulk"-sourced jobs — Add File (single) uploads share the
// same underlying queue (so they too survive navigation) but get their own
// SingleUploadToast instead of being folded into this multi-file "N of M"
// widget, which was designed around an actual batch and reads oddly for a
// single file.
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -17,7 +23,8 @@ const ACTIVE = new Set(["uploading", "queued"]);
const FAILED = new Set(["failed", "invalid"]);
export default function UploadProgressToast() {
const { jobs, retryJob } = useUploadQueue();
const { jobs: allJobs, retryJob } = useUploadQueue();
const jobs = allJobs.filter((j) => j.source === "bulk");
const navigate = useNavigate();
const [dismissed, setDismissed] = useState(false);
const [expanded, setExpanded] = useState(false);
@@ -91,7 +98,7 @@ export default function UploadProgressToast() {
<button
type="button"
className="w-full text-xs text-primary hover:underline py-2"
onClick={() => navigate("/admin/assets/add")}
onClick={() => navigate("/admin/assets/add/bulk")}
>
View upload details
</button>
+7 -55
View File
@@ -186,53 +186,6 @@ export function AssetsProvider({ children }) {
[request]
);
// ─── POST /api/admin/assets/presign + direct PUT + POST /api/admin/assets ──
//
// onProgress?: ({ phase: 'uploading'|'processing'|'done', pct }) => void
// "uploading" — browser -> storage, real bytes sent directly (this
// backend is never in that data path at all anymore).
// "processing" — brief server-side step once the upload lands: reads the
// object back (HeadObjectCommand), runs ffprobe for
// video/audio, inserts the DB row.
const uploadAsset = useCallback(
({ file, thumbnail, onProgress, ...rest }) =>
request(async () => {
const [mainPresign, thumbPresign] = await Promise.all([
presignAssetUpload(file),
thumbnail ? presignAssetUpload(thumbnail) : Promise.resolve(null),
]);
const storage_key = mainPresign.key;
await Promise.all([
uploadPresigned(file, mainPresign, (pct) => onProgress?.({ phase: "uploading", pct })),
thumbPresign ? uploadPresigned(thumbnail, thumbPresign) : Promise.resolve(),
]);
onProgress?.({ phase: "processing", pct: 100 });
const res = await api.post("/admin/assets", {
storage_key,
thumbnail_storage_key: thumbPresign?.key,
original_name: file.name,
// Fallback only — the backend prefers storage's own
// Content-Type, this just covers the rare case a browser
// sent the PUT with no Content-Type at all (empty File.type).
mimetype: file.type || undefined,
...rest,
});
const asset = res.data?.data?.data ?? null;
if (asset) {
setAssets((prev) => [asset, ...prev]);
invalidateListCache();
toast("Asset uploaded successfully.");
}
onProgress?.({ phase: "done", pct: 100 });
return res.data;
}),
[request]
);
// ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
//
// A replacement file (video thumbnail, or an image/audio asset's main
@@ -260,7 +213,7 @@ export function AssetsProvider({ children }) {
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
setSelectedAsset(asset);
invalidateListCache();
toast("Asset updated successfully.");
toast("File updated successfully.");
}
return res.data;
}),
@@ -277,7 +230,7 @@ export function AssetsProvider({ children }) {
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
invalidateListCache();
toast("Asset archived.");
toast("File archived.");
return res.data;
}),
[request]
@@ -292,7 +245,7 @@ export function AssetsProvider({ children }) {
});
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
invalidateListCache();
toast(`${ids.length} asset(s) archived.`);
toast(`${ids.length} file(s) archived.`);
return res.data;
}),
[request]
@@ -307,7 +260,7 @@ export function AssetsProvider({ children }) {
if (asset) {
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
invalidateListCache();
toast("Asset restored.");
toast("File restored.");
}
return res.data;
}),
@@ -321,7 +274,7 @@ export function AssetsProvider({ children }) {
const res = await api.patch("/admin/assets/bulk-restore", { ids });
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
invalidateListCache();
toast(`${ids.length} asset(s) restored.`);
toast(`${ids.length} file(s) restored.`);
return res.data;
}),
[request]
@@ -335,7 +288,7 @@ export function AssetsProvider({ children }) {
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
invalidateListCache();
toast("Asset permanently deleted.");
toast("File permanently deleted.");
return res.data;
}),
[request]
@@ -350,7 +303,7 @@ export function AssetsProvider({ children }) {
});
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
invalidateListCache();
toast(`${ids.length} asset(s) permanently deleted.`);
toast(`${ids.length} file(s) permanently deleted.`);
return res.data;
}),
[request]
@@ -380,7 +333,6 @@ export function AssetsProvider({ children }) {
fetchAssets,
fetchAsset,
fetchArchivedAssets,
uploadAsset,
updateAsset,
archiveAsset,
archiveAssets,
+12
View File
@@ -280,6 +280,17 @@ export function LibraryProvider({ children }) {
[request],
);
const attachLessonToUnits = useCallback(
(lessonId, unitIds) =>
request(async () => {
await Promise.all(
unitIds.map((unitId) => api.post(`${UNITS_BASE}/${unitId}/lessons`, { lesson_ids: [lessonId] }))
);
toast(`Attached to ${unitIds.length} unit${unitIds.length === 1 ? "" : "s"}.`);
}),
[request],
);
const reorderUnitLessons = useCallback(
(unitId, lessonIds) =>
request(async () => {
@@ -499,6 +510,7 @@ export function LibraryProvider({ children }) {
fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact,
fetchUnitFieldValues,
attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons,
attachLessonToUnits,
fetchUnitProduct, saveUnitProduct, removeUnitProduct,
// lesson library
+3 -3
View File
@@ -41,7 +41,7 @@ export function AdminTierCategoriesProvider({ children }) {
const createCategory = useCallback((payload) =>
request(async () => {
const { data } = await api.post("/admin/tiers/categories", payload);
toast("Tier category created.");
toast("Subscription category created.");
return data.data;
}), [request]);
@@ -52,7 +52,7 @@ export function AdminTierCategoriesProvider({ children }) {
prev.map((c) => (String(c.tier_category_id) === String(id) ? data.data : c))
);
if (category && String(category.tier_category_id) === String(id)) setCategory(data.data);
toast("Tier category updated.");
toast("Subscription category updated.");
return data.data;
}), [request, category]);
@@ -60,7 +60,7 @@ export function AdminTierCategoriesProvider({ children }) {
request(async () => {
await api.delete(`/admin/tiers/categories/${id}`);
setCategories((prev) => prev.filter((c) => String(c.tier_category_id) !== String(id)));
toast("Tier category deleted.");
toast("Subscription category deleted.");
return true;
}), [request]);
+5 -5
View File
@@ -167,7 +167,7 @@ export function AdminTiersProvider({ children }) {
try {
const { data } = await api.get(`/admin/tiers/users/${userId}/tiers`);
setUserTiers(data.data ?? []);
} catch { toast("Could not load user tiers."); }
} catch { toast("Could not load user subscriptions."); }
finally { setLoading(false); }
}, []);
@@ -175,10 +175,10 @@ export function AdminTiersProvider({ children }) {
setLoading(true);
try {
await api.post("/admin/tiers/users/tiers/grant", payload);
toast("Tier granted.");
toast("Subscription granted.");
return true;
} catch (err) {
toast(err?.response?.data?.message ?? "Could not grant tier.");
toast(err?.response?.data?.message ?? "Could not grant subscription.");
return false;
} finally { setLoading(false); }
}, []);
@@ -187,10 +187,10 @@ export function AdminTiersProvider({ children }) {
setLoading(true);
try {
await api.patch(`/admin/tiers/users/tiers/${tid}/revoke`);
toast("Tier revoked.");
toast("Subscription revoked.");
return true;
} catch (err) {
toast(err?.response?.data?.message ?? "Could not revoke tier.");
toast(err?.response?.data?.message ?? "Could not revoke subscription.");
return false;
} finally { setLoading(false); }
}, []);
+3 -3
View File
@@ -47,7 +47,7 @@ export function ClientTiersProvider({ children }) {
toast("Your subscription has expired. You've been moved to the Free plan.");
}
} catch (err) {
if (!silent) toast(err?.response?.data?.message ?? "Could not load tier.");
if (!silent) toast(err?.response?.data?.message ?? "Could not load subscription.");
} finally {
if (!silent) setTierLoading(false);
}
@@ -78,7 +78,7 @@ export function ClientTiersProvider({ children }) {
const { data } = await api.get("/client/tiers/me/history");
setTierHistory(data.data ?? []);
} catch (err) {
toast(err?.response?.data?.message ?? "Could not load tier history.");
toast(err?.response?.data?.message ?? "Could not load subscription history.");
} finally {
setTierHistoryLoading(false);
}
@@ -130,7 +130,7 @@ export function ClientTiersProvider({ children }) {
setCheckoutLoading(true);
try {
const { data } = await api.post("/client/tiers/checkout/capture", { order_id });
toast(data.message ?? "Payment successful. Tier activated.");
toast(data.message ?? "Payment successful. Subscription activated.");
setMyTier({ tier: data.data?.tier, expires_at: data.data?.expires_at, status: "active" });
// Refresh from server so the browser cache holds fresh Premium data — prevents
// subsequent getMyTier() calls from getting a stale 304 with the old Free/null response.
+49 -18
View File
@@ -1,9 +1,10 @@
// contexts/UploadQueueContext.jsx
//
// Global, app-shell-mounted upload queue for the Add Asset multi-file drop
// zone. Lives above the router (see AdminProvider.jsx) so an in-flight
// batch survives navigating away from the Add Asset page — the floating
// UploadProgressToast reads the same state from anywhere in /admin.
// Global, app-shell-mounted upload queue shared by both Add File (single)
// and Add Assets Bulk (multi-file drop zone). Lives above the router (see
// AdminProvider.jsx) so an in-flight upload survives navigating away from
// either page — the floating UploadProgressToast reads the same state from
// anywhere in /admin.
//
// Every job — whether it's part of an initial addBatch() or a single retry —
// uploads directly to storage via its own presigned PUT (see
@@ -52,18 +53,26 @@ export function UploadQueueProvider({ children }) {
// now go straight to storage instead of buffering through the backend.
const uploadOne = useCallback(async (job) => {
try {
const presigned = await presignAssetUpload(job.file);
const [presigned, thumbPresigned] = await Promise.all([
presignAssetUpload(job.file),
job.thumbnail ? presignAssetUpload(job.thumbnail) : Promise.resolve(null),
]);
const storage_key = presigned.key;
await uploadPresigned(job.file, presigned, (pct) => patchJobs([job.id], { progress: pct }));
await Promise.all([
uploadPresigned(job.file, presigned, (pct) => patchJobs([job.id], { progress: pct })),
thumbPresigned ? uploadPresigned(job.thumbnail, thumbPresigned) : Promise.resolve(),
]);
// Bulk display_name derivation only applies when the caller didn't
// already provide one — each job keeps whatever name it resolves to.
// display_name/description are never collected from the user anymore
// (Add File and Add Assets Bulk both dropped those fields) — every
// job's display_name is derived from its filename here.
const display_name = job.meta.display_name || baseNameOf(job.name);
const { data } = await api.post("/admin/assets", {
...job.meta,
storage_key,
thumbnail_storage_key: thumbPresigned?.key,
original_name: job.name,
display_name,
// Fallback only — the backend prefers storage's own
@@ -73,12 +82,15 @@ export function UploadQueueProvider({ children }) {
});
const asset = data?.data?.data;
patchJobs([job.id], { status: "uploaded", progress: 100, asset, error: null });
return { id: job.id, name: job.name, status: "uploaded", asset };
} catch (err) {
// No sonner toast() here — the floating widget (both corners
// would collide, see UploadProgressToast) already surfaces this
// via the job's own failed status.
// No sonner toast() here — the floating widget (see
// UploadProgressToast) already surfaces this via the job's own
// failed status; addBatch()'s onSettled callback gets this same
// result too, for callers (e.g. Add File) that want their own toast.
const message = err?.response?.data?.message ?? "Upload failed.";
patchJobs([job.id], { status: "failed", progress: 100, error: message });
return { id: job.id, name: job.name, status: "failed", error: message };
}
}, [patchJobs]);
@@ -91,29 +103,43 @@ export function UploadQueueProvider({ children }) {
const ids = batchJobs.map((j) => j.id);
patchJobs(ids, { status: "uploading", progress: 0 });
const results = new Array(batchJobs.length);
let next = 0;
const worker = async () => {
while (next < batchJobs.length) {
await uploadOne(batchJobs[next++]);
const i = next++;
results[i] = await uploadOne(batchJobs[i]);
}
};
await Promise.all(
Array.from({ length: Math.min(MAX_CONCURRENT, batchJobs.length) }, worker)
);
onSettledRef.current.get(batchId)?.();
onSettledRef.current.get(batchId)?.(results);
onSettledRef.current.delete(batchId);
}, [patchJobs, uploadOne]);
// files: File[]; meta: { is_public, storage_provider, createdBy }
const addBatch = useCallback((files, meta, { onSettled } = {}) => {
// items: (File | { file: File, thumbnail?: File })[] — a plain File is what
// the bulk drop zone passes; Add Asset (single) passes the { file,
// thumbnail } shape so its optional video/audio thumbnail rides along
// with the same job. meta: { is_public, storage_provider, createdBy }
// source: "single" | "bulk" — both flows share this one queue (and the
// floating UploadProgressToast) so an upload survives navigation either
// way, but Add Assets Bulk only lists jobs tagged "bulk" on its own page
// (see AddAssetsBulk.jsx) so a lone Add File upload doesn't show up
// there looking like a bulk batch that was never actually started.
const addBatch = useCallback((items, meta, { onSettled, source = "bulk" } = {}) => {
const batchId = nanoid();
const newJobs = files.map((file) => {
const newJobs = items.map((item) => {
const file = item instanceof File ? item : item.file;
const thumbnail = item instanceof File ? undefined : item.thumbnail;
const { ok, reason } = validateAssetFile(file);
return {
id: nanoid(),
batchId,
source,
file,
thumbnail,
name: file.name,
size: file.size,
mime: file.type,
@@ -153,8 +179,13 @@ export function UploadQueueProvider({ children }) {
setJobs((prev) => prev.filter((j) => j.id !== jobId || j.status === "uploading"));
}, []);
const clearFinished = useCallback(() => {
setJobs((prev) => prev.filter((j) => j.status === "uploading" || j.status === "queued"));
// source: restricts the clear to that source's finished jobs only, so
// clicking "Clear finished" on the Bulk page can't wipe out a still-shown
// Add File (single) entry in the floating toast, and vice versa.
const clearFinished = useCallback((source) => {
setJobs((prev) => prev.filter((j) =>
j.status === "uploading" || j.status === "queued" || (source && j.source !== source)
));
}, []);
return (
+16 -16
View File
@@ -105,16 +105,16 @@ export const ACTION_CONFIG = {
add_user_to_group: { label: "Added User to Group", group: "grp" },
remove_user_from_group:{ label: "Removed User from Group", group: "grp" },
// ── Tier Plans ──────────────────────────────────────────────────────────────
create_tier_plan: { label: "Created Tier Plan", group: "commerce" },
update_tier_plan: { label: "Updated Tier Plan", group: "commerce" },
archive_tier_plan: { label: "Archived Tier Plan", group: "commerce" },
restore_tier_plan: { label: "Restored Tier Plan", group: "commerce" },
bulk_archive_tier_plans: { label: "Bulk Archived Tier Plans", group: "commerce" },
bulk_restore_tier_plans: { label: "Bulk Restored Tier Plans", group: "commerce" },
// ── Subscriptions ───────────────────────────────────────────────────────────
create_tier_plan: { label: "Created Subscription", group: "commerce" },
update_tier_plan: { label: "Updated Subscription", group: "commerce" },
archive_tier_plan: { label: "Archived Subscription", group: "commerce" },
restore_tier_plan: { label: "Restored Subscription", group: "commerce" },
bulk_archive_tier_plans: { label: "Bulk Archived Subscriptions", group: "commerce" },
bulk_restore_tier_plans: { label: "Bulk Restored Subscriptions", group: "commerce" },
sync_plan_courses: { label: "Synced Plan Courses", group: "commerce" },
grant_tier: { label: "Granted Tier", group: "success" },
revoke_tier: { label: "Revoked Tier", group: "danger" },
grant_tier: { label: "Granted Subscription", group: "success" },
revoke_tier: { label: "Revoked Subscription", group: "danger" },
// ── Products & Categories ───────────────────────────────────────────────────
upsert_course_product: { label: "Set Course Product", group: "commerce" },
@@ -125,13 +125,13 @@ export const ACTION_CONFIG = {
archive_category: { label: "Archived Category", group: "commerce" },
restore_category: { label: "Restored Category", group: "commerce" },
// ── Assets ──────────────────────────────────────────────────────────────────
upload_asset: { label: "Uploaded Asset", group: "content" },
update_asset: { label: "Updated Asset", group: "content" },
archive_asset: { label: "Archived Asset", group: "content" },
restore_asset: { label: "Restored Asset", group: "content" },
bulk_archive_assets:{ label: "Bulk Archived Assets", group: "content" },
bulk_restore_assets:{ label: "Bulk Restored Assets", group: "content" },
// ── Files ───────────────────────────────────────────────────────────────────
upload_asset: { label: "Uploaded File", group: "content" },
update_asset: { label: "Updated File", group: "content" },
archive_asset: { label: "Archived File", group: "content" },
restore_asset: { label: "Restored File", group: "content" },
bulk_archive_assets:{ label: "Bulk Archived Files", group: "content" },
bulk_restore_assets:{ label: "Bulk Restored Files", group: "content" },
// ── Advertisements ──────────────────────────────────────────────────────────
create_advertisement: { label: "Created Ad", group: "content" },
+3 -3
View File
@@ -16,10 +16,10 @@ export const ADMIN_SECTIONS = [
id: "section-resources",
tab: "Resource Management",
title: "Resource Management",
description: "It includes assets management and tier plans.",
description: "It includes files management and subscriptions.",
tiles: [
{ key: "assets", label: "Assets", icon: FolderOpen, link: "/admin/assets" },
{ key: "tiers", label: "Tier Plans", icon: ShieldCheck, link: "/admin/tiers/plans" },
{ key: "assets", label: "Files", icon: FolderOpen, link: "/admin/assets" },
{ key: "tiers", label: "Subscriptions", icon: ShieldCheck, link: "/admin/tiers/plans" },
],
},
{
+1 -1
View File
@@ -20,7 +20,7 @@ export const JOB_LABELS = {
taskOverdue: { label: "Task Alerts (Admin)", description: "Automatically marks expired tasks as overdue or completed, and notifies admins." },
userNotifications: { label: "Task Alerts (Users)", description: "Notifies affected users when their tasks are automatically marked overdue or completed." },
issueCertificates: { label: "Certificate Issued", description: "Notifies users when a course certificate is ready." },
expireUserTiers: { label: "Tier Expired", description: "Notifies users when their subscription tier expires." },
expireUserTiers: { label: "Subscription Expired", description: "Notifies users when their subscription expires." },
};
// The only jobs that support a configurable target_status, and the values it accepts.
+1 -1
View File
@@ -11,7 +11,7 @@ export const TARGET_TYPE_OPTIONS = [
{ value: "both", label: "Admins & Users", icon: Megaphone, description: "Sent to admins and every active user", needsTarget: false },
{ value: "task_list", label: "Task List", icon: ListCheck, description: "Sent to everyone assigned to a specific task list", needsTarget: true },
{ value: "course", label: "Course", icon: BookText, description: "Sent to everyone with access to a specific course", needsTarget: true },
{ value: "tier_plan", label: "Tier Plan", icon: ShieldCheck, description: "Sent to everyone currently on a specific tier plan", needsTarget: true },
{ value: "tier_plan", label: "Subscription", icon: ShieldCheck, description: "Sent to everyone currently on a specific subscription plan", needsTarget: true },
];
export const TARGET_TYPE_MAP = Object.fromEntries(
+1 -1
View File
@@ -8,7 +8,7 @@
export const PLACEMENTS = [
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
{ key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Tier Plans", slotLabel: "Banner (above plan cards)" },
{ key: "tier_plans.banner", format: "banner", page: "tier_plans", pageLabel: "Subscriptions", slotLabel: "Banner (above plan cards)" },
{ key: "course_details.banner", format: "banner", page: "course_details", pageLabel: "Course Details", slotLabel: "Banner (below hero)" },
];
@@ -51,8 +51,8 @@ export default function ArchivedAssetsTable() {
const exportConfig = {
allData: assets,
attributes,
filename: `${getTimestamp()}_ArchivedAssets`,
sheetName: "Archived Assets",
filename: `${getTimestamp()}_ArchivedFiles`,
sheetName: "Archived Files",
generatedBy: formatGeneratedBy(currentUser),
};
@@ -102,7 +102,7 @@ export default function ArchivedAssetsTable() {
return (
<>
<DataTable
title="Archived Assets"
title="Archived Files"
data={assets}
columns={columns}
attributes={attributes}
@@ -125,8 +125,8 @@ export default function ArchivedAssetsTable() {
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="archived asset"
emptyMessage="No archived assets found."
recordLabel="archived file"
emptyMessage="No archived files found."
/>
{/* ── Single restore ── */}
@@ -134,7 +134,7 @@ export default function ArchivedAssetsTable() {
open={!!restoreTarget}
onOpenChange={(v) => !v && setRestoreTarget(null)}
entity={restoreTarget}
entityLabel="Asset"
entityLabel="File"
getName={(a) => a?.display_name ?? a?.original_name}
onRestore={(a) => restoreAsset(a?.asset_id)}
loading={loading}
@@ -146,7 +146,7 @@ export default function ArchivedAssetsTable() {
open={!!restoreIds}
onOpenChange={(v) => !v && setRestoreIds(null)}
ids={restoreIds ?? []}
entityLabel="Asset"
entityLabel="File"
onRestore={(ids) => restoreAssets(ids)}
loading={loading}
onSuccess={handleRestoreSuccess}
@@ -157,7 +157,7 @@ export default function ArchivedAssetsTable() {
open={!!deleteTarget}
onOpenChange={(v) => !v && setDeleteTarget(null)}
entity={deleteTarget}
entityLabel="Asset"
entityLabel="File"
getName={(a) => a?.display_name ?? a?.original_name}
onDelete={(a) => permanentlyDeleteAsset(a?.asset_id)}
loading={loading}
@@ -169,7 +169,7 @@ export default function ArchivedAssetsTable() {
open={!!deleteIds}
onOpenChange={(v) => !v && setDeleteIds(null)}
ids={deleteIds ?? []}
entityLabel="Asset"
entityLabel="File"
onDelete={(ids) => permanentlyDeleteAssets(ids)}
loading={loading}
onSuccess={handleDeleteSuccess}
@@ -45,8 +45,8 @@ export default function AssetsTable() {
const exportConfig = {
allData: assets,
attributes,
filename: `${getTimestamp()}_Assets`,
sheetName: "Assets",
filename: `${getTimestamp()}_Files`,
sheetName: "Files",
generatedBy: formatGeneratedBy(currentUser),
};
@@ -94,7 +94,7 @@ export default function AssetsTable() {
return (
<>
<DataTable
title="Assets"
title="Files"
data={assets}
columns={columns}
attributes={attributes}
@@ -117,8 +117,8 @@ export default function AssetsTable() {
columnPinning={columnPinning}
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="asset"
emptyMessage="No assets match the current filters."
recordLabel="file"
emptyMessage="No files match the current filters."
/>
{/* ── Single archive ── */}
@@ -126,7 +126,7 @@ export default function AssetsTable() {
open={!!archiveTarget}
onOpenChange={(v) => !v && setArchiveTarget(null)}
entity={archiveTarget}
entityLabel="Asset"
entityLabel="File"
getName={(a) => a?.display_name ?? a?.original_name}
onArchive={(a) => archiveAsset(a?.asset_id)}
loading={loading}
@@ -138,7 +138,7 @@ export default function AssetsTable() {
open={!!archiveIds}
onOpenChange={(v) => !v && setArchiveIds(null)}
ids={archiveIds ?? []}
entityLabel="Asset"
entityLabel="File"
onArchive={(ids) => archiveAssets(ids)}
loading={loading}
onSuccess={handleArchiveSuccess}
@@ -23,7 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
const TRIGGER_OPTIONS = [
{ value: "auth", label: "Auth (registration / login)" },
{ value: "tier", label: "Tier (subscription purchase)" },
{ value: "tier", label: "Subscription (plan purchase)" },
{ value: "course", label: "Course (lessons / quizzes)" },
{ value: "profile", label: "Profile completion" },
{ value: "social", label: "Social (referrals / community)" },
@@ -0,0 +1,124 @@
// AttachLessonToUnitsDialog — pick existing Units and attach this Lesson to them.
// Reverse direction of AttachLessonsDialog: unit_lessons has no exclusivity,
// so a Lesson can freely belong to several Units at once (multi-select).
import { useEffect, useMemo, useState } from "react";
import { Search, Link2 } from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import {
Dialog, DialogContent, DialogDescription, DialogFooter,
DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { formatDuration } from "@/utils/timestamp.util";
export default function AttachLessonToUnitsDialog({ open, onOpenChange, attachedUnitIds = [], onAttach, loading }) {
const { unitsFlat, fetchUnitsFlat, loading: libraryLoading } = useLibrary();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState([]);
useEffect(() => {
if (open) {
setSelected([]);
setQuery("");
fetchUnitsFlat();
}
}, [open, fetchUnitsFlat]);
const attachedSet = useMemo(
() => new Set(attachedUnitIds.map(String)),
[attachedUnitIds]
);
const candidates = useMemo(() => {
const q = query.trim().toLowerCase();
return (unitsFlat ?? [])
.filter((u) => !attachedSet.has(String(u.unit_id)))
.filter((u) => !q || u.title?.toLowerCase().includes(q));
}, [unitsFlat, attachedSet, query]);
const toggle = (unitId) =>
setSelected((prev) =>
prev.includes(unitId) ? prev.filter((id) => id !== unitId) : [...prev, unitId]
);
const handleAttach = async () => {
if (!selected.length) return;
await onAttach(selected);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link2 className="h-4 w-4" /> Select Units
</DialogTitle>
<DialogDescription>
Attach this lesson to one or more existing units without copying it.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search units..."
className="pl-8"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<ScrollArea className="h-64 rounded-md border">
{libraryLoading ? (
<div className="flex items-center justify-center h-full py-10">
<Spinner className="h-5 w-5" />
</div>
) : candidates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
{query ? "No units match your search." : "This lesson is already attached to every unit."}
</p>
) : (
<div className="divide-y">
{candidates.map((u) => (
<label
key={u.unit_id}
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/60 cursor-pointer"
>
<Checkbox
checked={selected.includes(u.unit_id)}
onCheckedChange={() => toggle(u.unit_id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium w-64 truncate" title={u.title}>
{u.title}
</p>
<p className="text-xs text-muted-foreground truncate">
{formatDuration(u.duration_seconds ?? 0)}
</p>
</div>
</label>
))}
</div>
)}
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleAttach} disabled={loading || !selected.length}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
Attach {selected.length > 0 ? `(${selected.length})` : ""}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -51,7 +51,7 @@ export default function ArchivedTierPlansTable() {
allData: plans,
attributes: planAttributes,
filename: `${getTimestamp()}_ArchivedTierPlans`,
sheetName: "Archived Tier Plans",
sheetName: "Archived Subscriptions",
generatedBy: formatGeneratedBy(currentUser),
}), [plans, planAttributes, currentUser]);
@@ -67,7 +67,7 @@ export default function TierPlansTable() {
allData: plans,
attributes: planAttributes,
filename: `${getTimestamp()}_TierPlans`,
sheetName: "Tier Plans",
sheetName: "Subscriptions",
generatedBy: formatGeneratedBy(currentUser),
}), [plans, planAttributes, currentUser]);
@@ -105,16 +105,16 @@ export default function TierPlansTable() {
<div className="rounded-lg border bg-card p-4 flex items-start gap-3 mb-4">
<Layers className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<p className="text-sm text-muted-foreground">
No tier categories defined.{" "}
No subscription categories defined.{" "}
<Link to="/admin/tiers/categories/add" className="underline text-primary font-medium">
Add a tier category
Add a subscription category
</Link>{" "}
before creating plans.
</p>
</div>
)}
<DataTable
title="Tier Plans"
title="Subscriptions"
data={plans}
columns={columns}
attributes={planAttributes}
@@ -138,7 +138,7 @@ export default function TierPlansTable() {
toolbarActions={toolbarActions}
selectionActions={selectionActions}
recordLabel="plan"
emptyMessage="No tier plans found."
emptyMessage="No subscriptions found."
/>
{/* Single archive — always force-revokes current subscribers' access (no refund), handled server-side */}
@@ -46,6 +46,6 @@ export function buildDataColumns(attributes, rowActions) {
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Asset Actions" }),
buildRowActionsColumn(rowActions, { dropdownLabel: "File Actions" }),
];
}
@@ -46,6 +46,6 @@ export function buildDataColumns(attributes, rowActions) {
return [
buildSelectionColumn(),
...buildColumns(visibleAttributes, { cellOverrides }),
buildRowActionsColumn(rowActions, { dropdownLabel: "Asset Actions" }),
buildRowActionsColumn(rowActions, { dropdownLabel: "File Actions" }),
];
}
@@ -42,7 +42,7 @@ export function buildToolbarActions({ fetchAssets, pagination, exportConfig, nav
key: "add-asset",
type: "button",
icon: <Plus className="h-3.5 w-3.5" />,
label: "Add Asset",
label: "Add File",
variant: "default",
className: "text-primary-foreground",
onClick: () => navigate("add"),
@@ -60,7 +60,7 @@ export function buildToolbarActions({ fetchAssets, pagination, exportConfig, nav
key: "archived-users",
type: "button",
icon: <Archive className="h-3.5 w-3.5" />,
label: "Archived Assets",
label: "Archived Files",
variant: "secondary",
className: "border border-border",
onClick: () => navigate("/admin/assets/archived"),
@@ -39,7 +39,7 @@ export function buildToolbarActions({
{
key: "categories",
type: "button",
label: "Tier Categories",
label: "Subscription Categories",
icon: <Layers className="h-3.5 w-3.5" />,
variant: "outline",
onClick: () => navigate("/admin/tiers/categories"),
@@ -55,7 +55,7 @@ const schema = z.object({
// ─── Steps ────────────────────────────────────────────────────────────────────
const ALL_STEPS = [
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose where this ad appears — Dashboard, Tier Plans, or Course Details." },
{ id: "placement", label: "Placement", icon: MapPin, description: "Choose where this ad appears — Dashboard, Subscriptions, or Course Details." },
{ id: "content", label: "Content", icon: FileText, description: "Badge labels, headline, description, image, and link for this ad." },
{ id: "scheduling", label: "Scheduling & Display", icon: CalendarClock, description: "Start/end dates and draft/active status." },
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this ad." },
@@ -267,7 +267,7 @@ export default function EditAdvertisement() {
</div>
</SectionCard>
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
<SectionCard title="Image" description="Choose an existing file from File Management.">
{selectedAsset ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
+37 -81
View File
@@ -5,19 +5,17 @@ import { Link, useNavigate } from "react-router-dom";
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react";
import { useAssets } from "@/contexts/AdminAssetsContext";
import { useUploadQueue } from "@/contexts/UploadQueueContext";
import { useAuth } from "@/contexts/AuthContext";
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";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
@@ -39,8 +37,6 @@ function resolveFileType(mimeType = "") {
// ─── 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"]),
});
@@ -119,16 +115,15 @@ function FieldError({ message }) {
export default function AddAsset() {
const navigate = useNavigate();
const { uploadAsset, loading } = useAssets();
const { fetchAssets } = useAssets();
const { addBatch } = useUploadQueue();
const { user } = useAuth();
const fileRef = useRef(null);
const thumbnailRef = useRef(null);
const [thumbKey, setThumbKey] = useState(0);
const [progress, setProgress] = useState(null); // { phase: 'uploading'|'processing'|'done'|'error', pct } | null
const {
register,
control,
handleSubmit,
setValue,
@@ -139,8 +134,6 @@ export default function AddAsset() {
} = useForm({
resolver: zodResolver(schema),
defaultValues: {
display_name: "",
description: "",
is_public: "false",
storage_provider: "s3", // ← changed from "chibisafe"
},
@@ -164,7 +157,6 @@ export default function AddAsset() {
}
fileRef.current = f;
setValue("_file", f);
if (!watch("display_name")) setValue("display_name", f.name);
clearErrors("_file");
};
@@ -174,40 +166,46 @@ export default function AddAsset() {
clearErrors("_thumbnail");
};
const onSubmit = async (data) => {
let hasFileError = false;
// Fire-and-forget: the job goes on the same global UploadQueueContext the
// Bulk flow uses, so it keeps uploading in the background no matter where
// you navigate to next — a single sonner toast.promise() tracks it
// through loading -> success/error instead of a dedicated widget.
const onSubmit = (data) => {
if (!fileRef.current) {
setError("_file", { message: "A file is required." });
hasFileError = true;
return;
}
if (hasFileError) return;
setProgress({ phase: "uploading", pct: 0 });
const result = await uploadAsset({
file: fileRef.current,
thumbnail: thumbnailRef.current ?? undefined,
display_name: data.display_name,
description: data.description ?? "",
file_type: fileType,
const fileName = fileRef.current.name;
const uploadPromise = new Promise((resolve, reject) => {
addBatch(
[{ file: fileRef.current, thumbnail: thumbnailRef.current ?? undefined }],
{
is_public: data.is_public === "true",
storage_provider: data.storage_provider,
createdBy: user?.user_id,
onProgress: setProgress,
},
{
source: "single",
onSettled: ([result]) => {
fetchAssets({ force: true });
if (result?.status === "uploaded") resolve(result);
else reject(new Error(result?.error || "Upload failed."));
},
}
);
});
setProgress(null);
if (result) { bypassOnce(); navigate("/admin/assets"); }
toast.promise(uploadPromise, {
loading: `Uploading ${fileName}…`,
success: (result) => `${result.name} uploaded successfully.`,
error: (err) => err.message || "Upload failed.",
});
bypassOnce();
navigate("/admin/assets");
};
const progressLabel = {
uploading: "Uploading…",
processing: "Processing…",
done: "Done.",
error: "Upload failed.",
}[progress?.phase];
return (
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
@@ -217,8 +215,8 @@ export default function AddAsset() {
<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>
<h1 className="text-xl font-semibold">Add File</h1>
<p className="text-sm text-muted-foreground">Upload a new file to the file library.</p>
</div>
</div>
@@ -236,7 +234,6 @@ export default function AddAsset() {
onClear={() => {
fileRef.current = null;
setValue("_file", null);
setValue("display_name", "");
clearErrors("_file");
}}
error={errors._file?.message}
@@ -277,30 +274,6 @@ export default function AddAsset() {
</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">
@@ -355,29 +328,12 @@ export default function AddAsset() {
</div>
{/* ── Upload progress (real — see AdminAssetsContext.uploadAsset) ── */}
{progress && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{progressLabel}</span>
<span>{progress.pct ?? 0}%</span>
</div>
<Progress value={progress.pct ?? 0} />
</div>
)}
{/* ── Actions ── */}
<div className="flex justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate("/admin/assets")}
disabled={loading}
>
<Button type="button" variant="outline" onClick={() => navigate("/admin/assets")}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Spinner className="h-4 w-4 mr-2" />}
<Button type="submit">
Upload Asset
</Button>
</div>
@@ -122,9 +122,14 @@ function FileRow({ job, onRetry, onRemove }) {
export default function AddAssetsBulk() {
const navigate = useNavigate();
const { fetchAssets } = useAssets();
const { jobs, addBatch, retryJob, removeJob, clearFinished } = useUploadQueue();
const { jobs: allJobs, addBatch, retryJob, removeJob, clearFinished } = useUploadQueue();
const { user } = useAuth();
// Add File (single) shares this same queue so its upload survives
// navigation too — but it's a separate flow the user never asked to
// batch, so it shouldn't appear here looking like part of a bulk run.
const jobs = allJobs.filter((j) => j.source === "bulk");
const [isPublic, setIsPublic] = useState("false");
// TEMPORARY: locked to S3 (see zrok tunnel note; revert after VPS migration)
const storageProvider = "s3";
@@ -140,6 +145,7 @@ export default function AddAssetsBulk() {
createdBy: user?.user_id,
}, {
onSettled: () => fetchAssets({ force: true }),
source: "bulk",
});
};
@@ -152,9 +158,9 @@ export default function AddAssetsBulk() {
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Uploading Assets</h1>
<h1 className="text-xl font-semibold">Uploading Files</h1>
<p className="text-sm text-muted-foreground">
{total ? `${total} file${total === 1 ? "" : "s"} · ${done} done · ${failed} failed` : "Upload one or more files to the asset library."}
{total ? `${total} file${total === 1 ? "" : "s"} · ${done} done · ${failed} failed` : "Upload one or more files to the file library."}
</p>
</div>
</div>
@@ -195,7 +201,7 @@ export default function AddAssetsBulk() {
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Files</span>
{(done > 0 || failed > 0) && (
<Button type="button" variant="ghost" size="sm" onClick={clearFinished}>
<Button type="button" variant="ghost" size="sm" onClick={() => clearFinished("bulk")}>
<Trash2 className="h-3.5 w-3.5 mr-1" /> Clear finished
</Button>
)}
@@ -6,7 +6,7 @@ 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: "Files", to: `/admin/assets` },
{ label: "Archived" },
];
+1 -1
View File
@@ -6,7 +6,7 @@ import AssetsTable from "../../components/assets/AssetsTable";
export default function AssetList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
{ label: "Assets" },
{ label: "Files" },
]
return (
+3 -3
View File
@@ -209,7 +209,7 @@ export default function EditAsset() {
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>
<p className="text-sm text-muted-foreground">File not found.</p>
</div>
);
}
@@ -223,7 +223,7 @@ export default function EditAsset() {
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">Edit Asset</h1>
<h1 className="text-xl font-semibold">Edit File</h1>
<p className="text-sm text-muted-foreground truncate max-w-sm">
{asset.original_name}
</p>
@@ -287,7 +287,7 @@ export default function EditAsset() {
</Label>
<Input
id="display_name"
placeholder="Friendly name for this asset"
placeholder="Friendly name for this file"
{...register("display_name")}
/>
<FieldError message={errors.display_name?.message} />
@@ -43,7 +43,7 @@ export default function ViewAudioAsset() {
if (notFound) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<p>File not found.</p>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
@@ -40,7 +40,7 @@ export default function ViewDocumentAsset() {
if (notFound) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<p>File not found.</p>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
@@ -51,7 +51,7 @@ export default function ViewImageAsset() {
if (notFound) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<p>File not found.</p>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
@@ -37,7 +37,7 @@ export default function ViewVideoAsset() {
if (notFound) {
return (
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
<p>Asset not found.</p>
<p>File not found.</p>
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
</div>
);
@@ -593,7 +593,7 @@ export default function AddCourse() {
className="h-7 text-xs"
>
<ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"}
{badgeImageUrl ? "Change" : "Pick from files"}
</Button>
{badgeImageUrl && (
<Button
@@ -21,10 +21,10 @@ export default function CourseList() {
{/* <div className="flex items-start gap-3 rounded-lg border bg-card p-4 text-sm">
<Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-muted-foreground">
Courses are organized under <span className="font-medium text-foreground">Tier Plans</span> — each course's{" "}
Courses are organized under <span className="font-medium text-foreground">Subscriptions</span> — each course's{" "}
<span className="font-medium text-foreground">Subscription</span> value determines which plan bundle it belongs to.{" "}
<Link to="/admin/tiers/plans" className="text-primary hover:underline font-medium">
Manage Tier Plans
Manage Subscriptions
</Link>
{" "}· Units and Lessons now run independently — build them once in the{" "}
<Link to="/admin/units" className="text-primary hover:underline font-medium">
@@ -988,7 +988,7 @@ export default function EditCourse() {
className="h-7 text-xs"
>
<ImagePlus className="h-3 w-3 mr-1" />
{badgeImageUrl ? "Change" : "Pick from assets"}
{badgeImageUrl ? "Change" : "Pick from files"}
</Button>
{badgeImageUrl && (
<Button
@@ -103,7 +103,7 @@ function StepDetails({ register, errors, control, setValue, tierCategories }) {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
<SelectValue placeholder="No subscription gate" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
@@ -156,7 +156,7 @@ export default function EditLesson() {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
<SelectValue placeholder="No subscription gate" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
@@ -201,7 +201,7 @@ export default function AddUnit() {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
<SelectValue placeholder="No subscription gate" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
@@ -128,7 +128,7 @@ export default function EditUnit() {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
<SelectValue placeholder="No subscription gate" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
@@ -87,7 +87,7 @@ function StepLesson({ register, errors, control, setValue, tierCategories }) {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
<SelectValue placeholder="No subscription gate" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
@@ -113,7 +113,7 @@ export default function EditLibraryLesson() {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
<SelectValue placeholder="No subscription gate" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
@@ -1,12 +1,13 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams, Link } from "react-router-dom";
import {
House, Pencil, LayoutTemplate, Clock, BookCheck,
House, Pencil, LayoutTemplate, Clock, BookCheck, Link2, Unlink,
} from "lucide-react";
import { useLibrary } from "@/contexts/AdminLibraryContext";
import { PageMeta } from "@/contexts/MetadataContext";
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
import AttachLessonToUnitsDialog from "../../../components/library/AttachLessonToUnitsDialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
@@ -16,8 +17,9 @@ import { PreviewChrome, PreviewContent } from "../../../components/courses/Lesso
export default function ViewLibraryLesson() {
const navigate = useNavigate();
const { lessonId } = useParams();
const { fetchLesson, lesson, loading } = useLibrary();
const { fetchLesson, lesson, loading, attachLessonToUnits, detachLessonFromUnit } = useLibrary();
const [initializing, setInitializing] = useState(true);
const [attachOpen, setAttachOpen] = useState(false);
useEffect(() => {
(async () => {
@@ -36,6 +38,16 @@ export default function ViewLibraryLesson() {
const blocks = lesson?.page?.blocks ?? [];
const objectives = lesson?.objectives ?? [];
const handleAttach = async (unitIds) => {
await attachLessonToUnits(lessonId, unitIds);
fetchLesson(lessonId);
};
const handleDetach = async (unitId) => {
await detachLessonFromUnit(unitId, lessonId);
fetchLesson(lessonId);
};
if (initializing) {
return (
<div className="flex items-center justify-center h-64">
@@ -64,6 +76,23 @@ export default function ViewLibraryLesson() {
)}
</div>
<div className="flex gap-2 shrink-0">
{units.length === 0 && (
<Button size="sm" variant="outline" onClick={() => setAttachOpen(true)}>
<Link2 className="h-3.5 w-3.5 mr-1.5" /> Attach to Unit
</Button>
)}
{units.map((u) => (
<Button
key={u.unit_id}
size="sm"
variant="destructive"
onClick={() => handleDetach(u.unit_id)}
disabled={loading}
title={`Detach from ${u.title} (lesson stays in library)`}
>
<Unlink className="h-3.5 w-3.5 mr-1.5" /> Detach from Unit
</Button>
))}
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/edit`)}>
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
</Button>
@@ -99,7 +128,7 @@ export default function ViewLibraryLesson() {
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Subscription</p>
<Badge variant={lesson?.subscription ? "outline" : "secondary"} className="capitalize">
{lesson?.subscription ?? "No tier gate"}
{lesson?.subscription ?? "No subscription gate"}
</Badge>
</div>
</div>
@@ -159,6 +188,14 @@ export default function ViewLibraryLesson() {
</div>
</div>
</div>
<AttachLessonToUnitsDialog
open={attachOpen}
onOpenChange={setAttachOpen}
attachedUnitIds={units.map((u) => u.unit_id)}
onAttach={handleAttach}
loading={loading}
/>
</section>
);
}
@@ -107,7 +107,7 @@ function StepUnit({ register, errors, control, setValue, tierCategories }) {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
<SelectValue placeholder="No subscription gate" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
@@ -113,7 +113,7 @@ export default function EditLibraryUnit() {
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="No tier gate" />
<SelectValue placeholder="No subscription gate" />
</SelectTrigger>
<SelectContent>
{tierCategories.map((c) => (
@@ -166,7 +166,7 @@ export default function ViewLibraryUnit() {
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Subscription</p>
<Badge variant={unit?.subscription ? "outline" : "secondary"} className="capitalize">
{unit?.subscription ?? "No tier gate"}
{unit?.subscription ?? "No subscription gate"}
</Badge>
</div>
</div>
+7 -7
View File
@@ -46,7 +46,7 @@ const BUNDLE_TYPES = [
];
const schema = z.object({
tier_category_id: z.string().min(1, "Tier category is required."),
tier_category_id: z.string().min(1, "Subscription category is required."),
label: z.string().min(1, "Label is required."),
description: z.string().optional(),
features: z.array(z.object({ text: z.string().min(1, "Feature cannot be empty.") })).default([]),
@@ -335,7 +335,7 @@ export default function AddPlan() {
</Button>
<div>
<h1 className="text-xl font-semibold">Add Plan</h1>
<p className="text-sm text-muted-foreground">Create a new paid tier plan.</p>
<p className="text-sm text-muted-foreground">Create a new paid subscription plan.</p>
</div>
</div>
@@ -345,17 +345,17 @@ export default function AddPlan() {
{/* ── Step 0: Bundles & Details ── */}
{currentStep === 0 && (
<SectionCard title="Bundles & Details" description="Which tier category this targets, what it unlocks, and how it's presented.">
<SectionCard title="Bundles & Details" description="Which subscription category this targets, what it unlocks, and how it's presented.">
<div className="space-y-1.5">
<Label>Tier Category <span className="text-destructive">*</span></Label>
<Label>Subscription Category <span className="text-destructive">*</span></Label>
{catLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Spinner className="h-4 w-4" /> Loading categories…
</div>
) : categories.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
No tier categories found.{" "}
No subscription categories found.{" "}
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
</p>
) : (
@@ -364,7 +364,7 @@ export default function AddPlan() {
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
>
<SelectTrigger>
<SelectValue placeholder="Select tier category" />
<SelectValue placeholder="Select subscription category" />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
@@ -560,7 +560,7 @@ export default function AddPlan() {
<SectionCard title="Review" description="Confirm everything before creating this plan.">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Tier Category</Label>
<Label className="text-xs text-muted-foreground">Subscription Category</Label>
<p className="text-sm">
{selectedCategory ? `${selectedCategory.name} (${selectedCategory.slug})` : "—"}
</p>
@@ -11,7 +11,7 @@ export default function ArchivedPlanList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" },
{ label: "Subscriptions", to: "/admin/tiers/plans" },
{ label: "Plans", to: "/admin/tiers/plans" },
{ label: "Archived" },
];
@@ -121,7 +121,7 @@ function BadgePicker({ currentAsset, selectedAsset, onSelect, onClear }) {
<div className="flex flex-col gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
{display ? "Change image" : "Pick from assets"}
{display ? "Change image" : "Pick from files"}
</Button>
{display && (
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={onClear}>
@@ -212,14 +212,14 @@ function EditTierCategoryInner({ isAdd }) {
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title={isAdd ? "Add Tier Category - STARR" : "Edit Tier Category - STARR"} />
<PageMeta title={isAdd ? "Add Subscription Category - STARR" : "Edit Subscription Category - STARR"} />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tier Categories", to: "/admin/tiers/categories" },
{ label: "Subscription Categories", to: "/admin/tiers/categories" },
{ label: isAdd ? "Add Category" : (category?.name ?? "Edit") },
]} />
</div>
@@ -229,9 +229,9 @@ function EditTierCategoryInner({ isAdd }) {
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">{isAdd ? "Add Tier Category" : "Edit Tier Category"}</h1>
<h1 className="text-xl font-semibold">{isAdd ? "Add Subscription Category" : "Edit Subscription Category"}</h1>
<p className="text-sm text-muted-foreground">
{isAdd ? "Define a new tier level for the platform." : "Update this tier category's details and badge."}
{isAdd ? "Define a new subscription level for the platform." : "Update this subscription category's details and badge."}
</p>
</div>
</div>
@@ -268,9 +268,9 @@ function EditTierCategoryInner({ isAdd }) {
<div className="space-y-1.5">
<Label htmlFor="rank">Rank (ordering)</Label>
<Input id="rank" type="number" min={isLocked ? 0 : 1} value={rank} onChange={(e) => setRank(e.target.value)} className="w-32" />
<p className="text-xs text-muted-foreground">Must be greater than 0. Free is rank 0. Higher rank = higher access tier.</p>
<p className="text-xs text-muted-foreground">Must be greater than 0. Free is rank 0. Higher rank = higher access subscription.</p>
{!isLocked && Number(rank) <= 0 && (
<p className="text-xs text-destructive">Rank must be at least 1 — rank 0 is reserved for the Free (default) tier.</p>
<p className="text-xs text-destructive">Rank must be at least 1 — rank 0 is reserved for the Free (default) subscription.</p>
)}
</div>
@@ -19,7 +19,7 @@ export default function PaymentList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" },
{ label: "Subscriptions", to: "/admin/tiers/plans" },
{ label: "Payments" },
];
+1 -1
View File
@@ -12,7 +12,7 @@ export default function PlanList() {
const items = [
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" },
{ label: "Subscriptions", to: "/admin/tiers/plans" },
{ label: "Plans" },
];
@@ -88,7 +88,7 @@ function SystemBadgeCard({ badge: initialBadge }) {
<div className="flex flex-col gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={() => setPickerOpen(true)}>
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
{displayAsset ? "Change image" : "Pick from assets"}
{displayAsset ? "Change image" : "Pick from files"}
</Button>
{displayAsset && (
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={handleClear}>
@@ -87,21 +87,21 @@ function TierCategoriesInner() {
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="Tier Categories - STARR" />
<PageMeta title="Subscription Categories - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
<div className="flex flex-col gap-2 mb-6">
<AppBreadcrumb items={[
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Tiers", to: "/admin/tiers/plans" },
{ label: "Tier Categories" },
{ label: "Subscriptions", to: "/admin/tiers/plans" },
{ label: "Subscription Categories" },
]} />
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-semibold">Tier Categories</h1>
<h1 className="text-xl font-semibold">Subscription Categories</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Define the tier levels available on the platform. Plans are built under each category.
</p>
@@ -125,7 +125,7 @@ function TierCategoriesInner() {
{loading && !categories.length ? (
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
) : !categories.length ? (
<p className="text-sm text-muted-foreground text-center py-12">No tier categories found.</p>
<p className="text-sm text-muted-foreground text-center py-12">No subscription categories found.</p>
) : (
<div className="space-y-3">
{categories.map((cat) => (
@@ -145,7 +145,7 @@ function TierCategoriesInner() {
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete Tier Category</DialogTitle>
<DialogTitle>Delete Subscription Category</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-semibold text-foreground">{deleteTarget?.name}</span>?
+10 -10
View File
@@ -111,7 +111,7 @@ export default function UserTierList() {
return (
<section className="bg-muted/60 min-h-full">
<PageMeta title="User Tier Management - STARR" />
<PageMeta title="User Subscription Management - STARR" />
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 py-10">
<div className="w-full max-w-2xl mx-auto">
@@ -120,7 +120,7 @@ export default function UserTierList() {
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
{ label: "Users", to: "/admin/users" },
{ label: `User #${userId}`, to: `/admin/users/view/${userId}` },
{ label: "Tiers" },
{ label: "Subscriptions" },
]} />
</div>
@@ -130,7 +130,7 @@ export default function UserTierList() {
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-semibold">User Tiers</h1>
<h1 className="text-xl font-semibold">User Subscriptions</h1>
<p className="text-sm text-muted-foreground">Tier history for User #{userId}</p>
</div>
</div>
@@ -144,7 +144,7 @@ export default function UserTierList() {
{activeTier && (
<div className="mb-5 rounded-lg border bg-card p-5 flex items-center justify-between gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wide">Current Tier</p>
<p className="text-xs text-muted-foreground uppercase tracking-wide">Current Subscription</p>
<div className="flex items-center gap-2">
<span className="text-sm px-3 py-0.5">{tierBadge(activeTier.tier)}</span>
{activeTier.expires_at && (
@@ -169,9 +169,9 @@ export default function UserTierList() {
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
</div>
) : (
<SectionCard icon={BadgeCheck} title="Tier History">
<SectionCard icon={BadgeCheck} title="Subscription History">
{userTiers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No tier records found.</p>
<p className="text-sm text-muted-foreground text-center py-4">No subscription records found.</p>
) : (
<div className="space-y-4">
{userTiers.map((t) => (
@@ -206,11 +206,11 @@ export default function UserTierList() {
<Dialog open={grantOpen} onOpenChange={setGrantOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Grant Tier</DialogTitle>
<DialogTitle>Grant Subscription</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label>Tier</Label>
<Label>Subscription</Label>
<Select
value={grantForm.tier}
onValueChange={(v) => setGrantForm((p) => ({ ...p, tier: v, plan_id: "" }))}
@@ -232,7 +232,7 @@ export default function UserTierList() {
disabled={!filteredPlans.length}
>
<SelectTrigger>
<SelectValue placeholder={filteredPlans.length ? "Select a plan" : "No active plans for this tier"} />
<SelectValue placeholder={filteredPlans.length ? "Select a plan" : "No active plans for this subscription"} />
</SelectTrigger>
<SelectContent>
{filteredPlans.map((p) => (
@@ -271,7 +271,7 @@ export default function UserTierList() {
<AlertDialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke tier?</AlertDialogTitle>
<AlertDialogTitle>Revoke subscription?</AlertDialogTitle>
<AlertDialogDescription>
The user's <strong className="capitalize">{revokeTarget?.tier}</strong> tier will be revoked
and they'll be automatically downgraded to Free.
@@ -204,7 +204,7 @@ export default function ViewPayment() {
<SectionCard icon={BadgeCheck} title="Plan">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{payment.plan.label}</InfoRow>
<InfoRow label="Tier">
<InfoRow label="Subscription">
{(() => { const { cls, label } = resolveTierBadge(payment.plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
</InfoRow>
<InfoRow label="Duration">{payment.plan.duration_days} days</InfoRow>
+1 -1
View File
@@ -97,7 +97,7 @@ function PlanDetailsTab({ plan, loading, tierMap, assignedCourses, coursesLoadin
<SectionCard icon={Tag} title="Plan Details">
<div className="grid grid-cols-2 gap-4">
<InfoRow label="Label">{plan.label}</InfoRow>
<InfoRow label="Tier">
<InfoRow label="Subscription">
{(() => {
const { cls, label } = resolveTierBadge(plan.tier, tierMap);
return <Badge className={`${cls} mt-0.5`}>{label}</Badge>;
@@ -58,7 +58,7 @@ export default function LockedContentPanel({ course, item, tierMap = {}, checkou
<Zap className="size-4" /> View Available Plans
</Button>
</div>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
</div>
</div>
);
+1 -1
View File
@@ -851,7 +851,7 @@ const UnitList = () => {
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
</div>
</div>
);
+1 -1
View File
@@ -344,7 +344,7 @@ const LockedContent = () => {
<Button onClick={() => navigate('/plans')} className="gap-1.5">
<Zap className="size-4" /> View Available Plans
</Button>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this tier.</p>
<p className="text-xs text-muted-foreground">Already subscribed? Your plan may not cover this subscription.</p>
</div>
</div>
);