diff --git a/src/components/generic/AssetPickerSheet.jsx b/src/components/generic/AssetPickerSheet.jsx index 25e776a..7acc905 100644 --- a/src/components/generic/AssetPickerSheet.jsx +++ b/src/components/generic/AssetPickerSheet.jsx @@ -74,7 +74,7 @@ function AssetCard({ asset, streamSrc, selected, onSelect }) { function EmptyState({ fileType }) { return (
-

No {fileType} assets found.

+

No {fileType} files found.

); } @@ -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 ── */} Select {label} - Click an asset to attach it. + Click a file to attach it. {/* ── Search + Filter ── */} diff --git a/src/components/generic/BroadcastTargetPicker.jsx b/src/components/generic/BroadcastTargetPicker.jsx index f6bc9f9..f7e8d57 100644 --- a/src/components/generic/BroadcastTargetPicker.jsx +++ b/src/components/generic/BroadcastTargetPicker.jsx @@ -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…", }, }; diff --git a/src/components/generic/DashboardGrid.jsx b/src/components/generic/DashboardGrid.jsx index 13e0406..fa941f2 100644 --- a/src/components/generic/DashboardGrid.jsx +++ b/src/components/generic/DashboardGrid.jsx @@ -12,12 +12,12 @@ export default function DashboardGrid({ sections = [] }) { return (
-
+
{sections.map(({ title, description, tiles }) => (
{/* Header */} -
+

{title}

diff --git a/src/components/generic/UploadProgressToast.jsx b/src/components/generic/UploadProgressToast.jsx index 8ed1327..9362507 100644 --- a/src/components/generic/UploadProgressToast.jsx +++ b/src/components/generic/UploadProgressToast.jsx @@ -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() { diff --git a/src/contexts/AdminAssetsContext.jsx b/src/contexts/AdminAssetsContext.jsx index 536bedd..7673861 100644 --- a/src/contexts/AdminAssetsContext.jsx +++ b/src/contexts/AdminAssetsContext.jsx @@ -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, diff --git a/src/contexts/AdminLibraryContext.jsx b/src/contexts/AdminLibraryContext.jsx index 6a1c8f9..b812a2a 100644 --- a/src/contexts/AdminLibraryContext.jsx +++ b/src/contexts/AdminLibraryContext.jsx @@ -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 diff --git a/src/contexts/AdminTierCategoriesContext.jsx b/src/contexts/AdminTierCategoriesContext.jsx index 834b305..6440f1e 100644 --- a/src/contexts/AdminTierCategoriesContext.jsx +++ b/src/contexts/AdminTierCategoriesContext.jsx @@ -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]); diff --git a/src/contexts/AdminTiersContext.jsx b/src/contexts/AdminTiersContext.jsx index 7ce7c13..8c28b2f 100644 --- a/src/contexts/AdminTiersContext.jsx +++ b/src/contexts/AdminTiersContext.jsx @@ -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); } }, []); diff --git a/src/contexts/ClientTiersProvider.jsx b/src/contexts/ClientTiersProvider.jsx index eaf5e06..81aac36 100644 --- a/src/contexts/ClientTiersProvider.jsx +++ b/src/contexts/ClientTiersProvider.jsx @@ -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. diff --git a/src/contexts/UploadQueueContext.jsx b/src/contexts/UploadQueueContext.jsx index 8b622e6..0bb9b25 100644 --- a/src/contexts/UploadQueueContext.jsx +++ b/src/contexts/UploadQueueContext.jsx @@ -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 ( diff --git a/src/data/activity.data.js b/src/data/activity.data.js index 9a0efb5..0e382c5 100644 --- a/src/data/activity.data.js +++ b/src/data/activity.data.js @@ -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" }, diff --git a/src/data/adminTiles.data.js b/src/data/adminTiles.data.js index 80f1951..3fce239 100644 --- a/src/data/adminTiles.data.js +++ b/src/data/adminTiles.data.js @@ -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" }, ], }, { diff --git a/src/data/cronPresets.data.js b/src/data/cronPresets.data.js index 7a3e500..709ef43 100644 --- a/src/data/cronPresets.data.js +++ b/src/data/cronPresets.data.js @@ -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. diff --git a/src/data/notificationBroadcast.data.js b/src/data/notificationBroadcast.data.js index 8305f0f..78b4482 100644 --- a/src/data/notificationBroadcast.data.js +++ b/src/data/notificationBroadcast.data.js @@ -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( diff --git a/src/data/placement.data.js b/src/data/placement.data.js index 41f26f8..207eec2 100644 --- a/src/data/placement.data.js +++ b/src/data/placement.data.js @@ -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)" }, ]; diff --git a/src/modules/admin/components/assets/ArchivedAssetsTable.jsx b/src/modules/admin/components/assets/ArchivedAssetsTable.jsx index f0862d2..9216633 100644 --- a/src/modules/admin/components/assets/ArchivedAssetsTable.jsx +++ b/src/modules/admin/components/assets/ArchivedAssetsTable.jsx @@ -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 ( <> {/* ── 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} diff --git a/src/modules/admin/components/assets/AssetsTable.jsx b/src/modules/admin/components/assets/AssetsTable.jsx index b764e38..cfc6a85 100644 --- a/src/modules/admin/components/assets/AssetsTable.jsx +++ b/src/modules/admin/components/assets/AssetsTable.jsx @@ -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 ( <> {/* ── 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} diff --git a/src/modules/admin/components/courses/CreateAchievementDialog.jsx b/src/modules/admin/components/courses/CreateAchievementDialog.jsx index 1f43a03..76faf0d 100644 --- a/src/modules/admin/components/courses/CreateAchievementDialog.jsx +++ b/src/modules/admin/components/courses/CreateAchievementDialog.jsx @@ -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)" }, diff --git a/src/modules/admin/components/library/AttachLessonToUnitsDialog.jsx b/src/modules/admin/components/library/AttachLessonToUnitsDialog.jsx new file mode 100644 index 0000000..3e70dbf --- /dev/null +++ b/src/modules/admin/components/library/AttachLessonToUnitsDialog.jsx @@ -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 ( + + + + + Select Units + + + Attach this lesson to one or more existing units without copying it. + + + +
+ + setQuery(e.target.value)} + /> +
+ + + {libraryLoading ? ( +
+ +
+ ) : candidates.length === 0 ? ( +

+ {query ? "No units match your search." : "This lesson is already attached to every unit."} +

+ ) : ( +
+ {candidates.map((u) => ( + + ))} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/src/modules/admin/components/tiers/ArchivedTierPlansTable.jsx b/src/modules/admin/components/tiers/ArchivedTierPlansTable.jsx index 7476fdd..e53a6f7 100644 --- a/src/modules/admin/components/tiers/ArchivedTierPlansTable.jsx +++ b/src/modules/admin/components/tiers/ArchivedTierPlansTable.jsx @@ -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]); diff --git a/src/modules/admin/components/tiers/TierPlansTable.jsx b/src/modules/admin/components/tiers/TierPlansTable.jsx index 8b6a880..5cba14a 100644 --- a/src/modules/admin/components/tiers/TierPlansTable.jsx +++ b/src/modules/admin/components/tiers/TierPlansTable.jsx @@ -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() {

- No tier categories defined.{" "} + No subscription categories defined.{" "} - Add a tier category + Add a subscription category {" "} before creating plans.

)} {/* Single archive — always force-revokes current subscribers' access (no refund), handled server-side */} diff --git a/src/modules/admin/config/assets/archive/columns.config.jsx b/src/modules/admin/config/assets/archive/columns.config.jsx index ffe9b85..6106ff2 100644 --- a/src/modules/admin/config/assets/archive/columns.config.jsx +++ b/src/modules/admin/config/assets/archive/columns.config.jsx @@ -46,6 +46,6 @@ export function buildDataColumns(attributes, rowActions) { return [ buildSelectionColumn(), ...buildColumns(visibleAttributes, { cellOverrides }), - buildRowActionsColumn(rowActions, { dropdownLabel: "Asset Actions" }), + buildRowActionsColumn(rowActions, { dropdownLabel: "File Actions" }), ]; } \ No newline at end of file diff --git a/src/modules/admin/config/assets/columns.config.jsx b/src/modules/admin/config/assets/columns.config.jsx index ffe9b85..6106ff2 100644 --- a/src/modules/admin/config/assets/columns.config.jsx +++ b/src/modules/admin/config/assets/columns.config.jsx @@ -46,6 +46,6 @@ export function buildDataColumns(attributes, rowActions) { return [ buildSelectionColumn(), ...buildColumns(visibleAttributes, { cellOverrides }), - buildRowActionsColumn(rowActions, { dropdownLabel: "Asset Actions" }), + buildRowActionsColumn(rowActions, { dropdownLabel: "File Actions" }), ]; } \ No newline at end of file diff --git a/src/modules/admin/config/assets/toolbar.config.jsx b/src/modules/admin/config/assets/toolbar.config.jsx index 07bc600..59ed456 100644 --- a/src/modules/admin/config/assets/toolbar.config.jsx +++ b/src/modules/admin/config/assets/toolbar.config.jsx @@ -42,7 +42,7 @@ export function buildToolbarActions({ fetchAssets, pagination, exportConfig, nav key: "add-asset", type: "button", icon: , - 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: , - label: "Archived Assets", + label: "Archived Files", variant: "secondary", className: "border border-border", onClick: () => navigate("/admin/assets/archived"), diff --git a/src/modules/admin/config/tiers/plans/toolbar.config.jsx b/src/modules/admin/config/tiers/plans/toolbar.config.jsx index c371316..e81a88a 100644 --- a/src/modules/admin/config/tiers/plans/toolbar.config.jsx +++ b/src/modules/admin/config/tiers/plans/toolbar.config.jsx @@ -39,7 +39,7 @@ export function buildToolbarActions({ { key: "categories", type: "button", - label: "Tier Categories", + label: "Subscription Categories", icon: , variant: "outline", onClick: () => navigate("/admin/tiers/categories"), diff --git a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx index 4bc264f..4a348a1 100644 --- a/src/modules/admin/pages/advertisements/AddAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/AddAdvertisement.jsx @@ -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." }, diff --git a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx index a136941..04d0564 100644 --- a/src/modules/admin/pages/advertisements/EditAdvertisement.jsx +++ b/src/modules/admin/pages/advertisements/EditAdvertisement.jsx @@ -267,7 +267,7 @@ export default function EditAdvertisement() {
- + {selectedAsset ? (
{ - 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 (
@@ -217,8 +215,8 @@ export default function AddAsset() {
-

Add Asset

-

Upload a new file to the asset library.

+

Add File

+

Upload a new file to the file library.

@@ -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() {
)} - {/* ── Display Name ── */} -
- - - -
- - {/* ── Description ── */} -
- -