change things
This commit is contained in:
@@ -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…",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}, []);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
is_public: data.is_public === "true",
|
||||
storage_provider: data.storage_provider,
|
||||
createdBy: user?.user_id,
|
||||
onProgress: setProgress,
|
||||
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,
|
||||
},
|
||||
{
|
||||
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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>?
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user