mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
change things
This commit is contained in:
@@ -74,7 +74,7 @@ function AssetCard({ asset, streamSrc, selected, onSelect }) {
|
|||||||
function EmptyState({ fileType }) {
|
function EmptyState({ fileType }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-48 gap-2">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -210,7 +210,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
|
|||||||
|
|
||||||
const label = fileType
|
const label = fileType
|
||||||
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
|
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
|
||||||
: "Assets";
|
: "Files";
|
||||||
|
|
||||||
const hasActiveFilters = activeExts.size > 0;
|
const hasActiveFilters = activeExts.size > 0;
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allow
|
|||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b">
|
||||||
<SheetTitle>Select {label}</SheetTitle>
|
<SheetTitle>Select {label}</SheetTitle>
|
||||||
<SheetDescription>Click an asset to attach it.</SheetDescription>
|
<SheetDescription>Click a file to attach it.</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
{/* ── Search + Filter ── */}
|
{/* ── Search + Filter ── */}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const TARGET_CONFIGS = {
|
|||||||
fetch: () => api.get("/admin/tiers", { params: { limit: 100 } }).then((res) => res.data?.data?.data ?? []),
|
fetch: () => api.get("/admin/tiers", { params: { limit: 100 } }).then((res) => res.data?.data?.data ?? []),
|
||||||
idKey: "plan_id",
|
idKey: "plan_id",
|
||||||
labelKey: "label",
|
labelKey: "label",
|
||||||
placeholder: "Select a tier plan…",
|
placeholder: "Select a subscription plan…",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export default function DashboardGrid({ sections = [] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-muted/60">
|
<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 }) => (
|
{sections.map(({ title, description, tiles }) => (
|
||||||
<div key={title} className="flex flex-col items-start gap-4 w-full">
|
<div key={title} className="flex flex-col items-start gap-4 w-full">
|
||||||
|
|
||||||
{/* Header */}
|
{/* 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">
|
<h1 className="text-2xl leading-tighter font-medium tracking-tighter">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
// components/generic/UploadProgressToast.jsx
|
// components/generic/UploadProgressToast.jsx
|
||||||
//
|
//
|
||||||
// Floating widget mounted once in AdminLayout (outside the router Outlet's
|
// Floating widget mounted once in AdminLayout (outside the router Outlet's
|
||||||
// unmount cycle) so it keeps showing asset-upload progress no matter what
|
// unmount cycle) so it keeps showing Add Assets Bulk's upload progress no
|
||||||
// admin page you navigate to mid-upload. State comes from UploadQueueContext,
|
// matter what admin page you navigate to mid-batch. State comes from
|
||||||
// which lives above the router for the same reason.
|
// 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 { useEffect, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -17,7 +23,8 @@ const ACTIVE = new Set(["uploading", "queued"]);
|
|||||||
const FAILED = new Set(["failed", "invalid"]);
|
const FAILED = new Set(["failed", "invalid"]);
|
||||||
|
|
||||||
export default function UploadProgressToast() {
|
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 navigate = useNavigate();
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -91,7 +98,7 @@ export default function UploadProgressToast() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full text-xs text-primary hover:underline py-2"
|
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
|
View upload details
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -186,53 +186,6 @@ export function AssetsProvider({ children }) {
|
|||||||
[request]
|
[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 ────────────────────────────────────
|
// ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
|
||||||
//
|
//
|
||||||
// A replacement file (video thumbnail, or an image/audio asset's main
|
// 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)));
|
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
|
||||||
setSelectedAsset(asset);
|
setSelectedAsset(asset);
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast("Asset updated successfully.");
|
toast("File updated successfully.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -277,7 +230,7 @@ export function AssetsProvider({ children }) {
|
|||||||
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
||||||
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast("Asset archived.");
|
toast("File archived.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -292,7 +245,7 @@ export function AssetsProvider({ children }) {
|
|||||||
});
|
});
|
||||||
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast(`${ids.length} asset(s) archived.`);
|
toast(`${ids.length} file(s) archived.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -307,7 +260,7 @@ export function AssetsProvider({ children }) {
|
|||||||
if (asset) {
|
if (asset) {
|
||||||
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast("Asset restored.");
|
toast("File restored.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -321,7 +274,7 @@ export function AssetsProvider({ children }) {
|
|||||||
const res = await api.patch("/admin/assets/bulk-restore", { ids });
|
const res = await api.patch("/admin/assets/bulk-restore", { ids });
|
||||||
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast(`${ids.length} asset(s) restored.`);
|
toast(`${ids.length} file(s) restored.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -335,7 +288,7 @@ export function AssetsProvider({ children }) {
|
|||||||
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
||||||
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast("Asset permanently deleted.");
|
toast("File permanently deleted.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -350,7 +303,7 @@ export function AssetsProvider({ children }) {
|
|||||||
});
|
});
|
||||||
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
||||||
invalidateListCache();
|
invalidateListCache();
|
||||||
toast(`${ids.length} asset(s) permanently deleted.`);
|
toast(`${ids.length} file(s) permanently deleted.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -380,7 +333,6 @@ export function AssetsProvider({ children }) {
|
|||||||
fetchAssets,
|
fetchAssets,
|
||||||
fetchAsset,
|
fetchAsset,
|
||||||
fetchArchivedAssets,
|
fetchArchivedAssets,
|
||||||
uploadAsset,
|
|
||||||
updateAsset,
|
updateAsset,
|
||||||
archiveAsset,
|
archiveAsset,
|
||||||
archiveAssets,
|
archiveAssets,
|
||||||
|
|||||||
@@ -280,6 +280,17 @@ export function LibraryProvider({ children }) {
|
|||||||
[request],
|
[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(
|
const reorderUnitLessons = useCallback(
|
||||||
(unitId, lessonIds) =>
|
(unitId, lessonIds) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -499,6 +510,7 @@ export function LibraryProvider({ children }) {
|
|||||||
fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact,
|
fetchUnitArchiveImpact, fetchUnitPermanentDeleteImpact,
|
||||||
fetchUnitFieldValues,
|
fetchUnitFieldValues,
|
||||||
attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons,
|
attachLessonsToUnit, detachLessonFromUnit, detachUnitFromCourse, attachUnitToCourses, reorderUnitLessons,
|
||||||
|
attachLessonToUnits,
|
||||||
fetchUnitProduct, saveUnitProduct, removeUnitProduct,
|
fetchUnitProduct, saveUnitProduct, removeUnitProduct,
|
||||||
|
|
||||||
// lesson library
|
// lesson library
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function AdminTierCategoriesProvider({ children }) {
|
|||||||
const createCategory = useCallback((payload) =>
|
const createCategory = useCallback((payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.post("/admin/tiers/categories", payload);
|
const { data } = await api.post("/admin/tiers/categories", payload);
|
||||||
toast("Tier category created.");
|
toast("Subscription category created.");
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [request]);
|
}), [request]);
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ export function AdminTierCategoriesProvider({ children }) {
|
|||||||
prev.map((c) => (String(c.tier_category_id) === String(id) ? data.data : c))
|
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);
|
if (category && String(category.tier_category_id) === String(id)) setCategory(data.data);
|
||||||
toast("Tier category updated.");
|
toast("Subscription category updated.");
|
||||||
return data.data;
|
return data.data;
|
||||||
}), [request, category]);
|
}), [request, category]);
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ export function AdminTierCategoriesProvider({ children }) {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
await api.delete(`/admin/tiers/categories/${id}`);
|
await api.delete(`/admin/tiers/categories/${id}`);
|
||||||
setCategories((prev) => prev.filter((c) => String(c.tier_category_id) !== String(id)));
|
setCategories((prev) => prev.filter((c) => String(c.tier_category_id) !== String(id)));
|
||||||
toast("Tier category deleted.");
|
toast("Subscription category deleted.");
|
||||||
return true;
|
return true;
|
||||||
}), [request]);
|
}), [request]);
|
||||||
|
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export function AdminTiersProvider({ children }) {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.get(`/admin/tiers/users/${userId}/tiers`);
|
const { data } = await api.get(`/admin/tiers/users/${userId}/tiers`);
|
||||||
setUserTiers(data.data ?? []);
|
setUserTiers(data.data ?? []);
|
||||||
} catch { toast("Could not load user tiers."); }
|
} catch { toast("Could not load user subscriptions."); }
|
||||||
finally { setLoading(false); }
|
finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -175,10 +175,10 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post("/admin/tiers/users/tiers/grant", payload);
|
await api.post("/admin/tiers/users/tiers/grant", payload);
|
||||||
toast("Tier granted.");
|
toast("Subscription granted.");
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err?.response?.data?.message ?? "Could not grant tier.");
|
toast(err?.response?.data?.message ?? "Could not grant subscription.");
|
||||||
return false;
|
return false;
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -187,10 +187,10 @@ export function AdminTiersProvider({ children }) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.patch(`/admin/tiers/users/tiers/${tid}/revoke`);
|
await api.patch(`/admin/tiers/users/tiers/${tid}/revoke`);
|
||||||
toast("Tier revoked.");
|
toast("Subscription revoked.");
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err?.response?.data?.message ?? "Could not revoke tier.");
|
toast(err?.response?.data?.message ?? "Could not revoke subscription.");
|
||||||
return false;
|
return false;
|
||||||
} finally { setLoading(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.");
|
toast("Your subscription has expired. You've been moved to the Free plan.");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
if (!silent) setTierLoading(false);
|
if (!silent) setTierLoading(false);
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ export function ClientTiersProvider({ children }) {
|
|||||||
const { data } = await api.get("/client/tiers/me/history");
|
const { data } = await api.get("/client/tiers/me/history");
|
||||||
setTierHistory(data.data ?? []);
|
setTierHistory(data.data ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err?.response?.data?.message ?? "Could not load tier history.");
|
toast(err?.response?.data?.message ?? "Could not load subscription history.");
|
||||||
} finally {
|
} finally {
|
||||||
setTierHistoryLoading(false);
|
setTierHistoryLoading(false);
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ export function ClientTiersProvider({ children }) {
|
|||||||
setCheckoutLoading(true);
|
setCheckoutLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post("/client/tiers/checkout/capture", { order_id });
|
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" });
|
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
|
// 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.
|
// subsequent getMyTier() calls from getting a stale 304 with the old Free/null response.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// contexts/UploadQueueContext.jsx
|
// contexts/UploadQueueContext.jsx
|
||||||
//
|
//
|
||||||
// Global, app-shell-mounted upload queue for the Add Asset multi-file drop
|
// Global, app-shell-mounted upload queue shared by both Add File (single)
|
||||||
// zone. Lives above the router (see AdminProvider.jsx) so an in-flight
|
// and Add Assets Bulk (multi-file drop zone). Lives above the router (see
|
||||||
// batch survives navigating away from the Add Asset page — the floating
|
// AdminProvider.jsx) so an in-flight upload survives navigating away from
|
||||||
// UploadProgressToast reads the same state from anywhere in /admin.
|
// 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 —
|
// Every job — whether it's part of an initial addBatch() or a single retry —
|
||||||
// uploads directly to storage via its own presigned PUT (see
|
// 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.
|
// now go straight to storage instead of buffering through the backend.
|
||||||
const uploadOne = useCallback(async (job) => {
|
const uploadOne = useCallback(async (job) => {
|
||||||
try {
|
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;
|
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
|
// display_name/description are never collected from the user anymore
|
||||||
// already provide one — each job keeps whatever name it resolves to.
|
// (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 display_name = job.meta.display_name || baseNameOf(job.name);
|
||||||
|
|
||||||
const { data } = await api.post("/admin/assets", {
|
const { data } = await api.post("/admin/assets", {
|
||||||
...job.meta,
|
...job.meta,
|
||||||
storage_key,
|
storage_key,
|
||||||
|
thumbnail_storage_key: thumbPresigned?.key,
|
||||||
original_name: job.name,
|
original_name: job.name,
|
||||||
display_name,
|
display_name,
|
||||||
// Fallback only — the backend prefers storage's own
|
// Fallback only — the backend prefers storage's own
|
||||||
@@ -73,12 +82,15 @@ export function UploadQueueProvider({ children }) {
|
|||||||
});
|
});
|
||||||
const asset = data?.data?.data;
|
const asset = data?.data?.data;
|
||||||
patchJobs([job.id], { status: "uploaded", progress: 100, asset, error: null });
|
patchJobs([job.id], { status: "uploaded", progress: 100, asset, error: null });
|
||||||
|
return { id: job.id, name: job.name, status: "uploaded", asset };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// No sonner toast() here — the floating widget (both corners
|
// No sonner toast() here — the floating widget (see
|
||||||
// would collide, see UploadProgressToast) already surfaces this
|
// UploadProgressToast) already surfaces this via the job's own
|
||||||
// via the job's own failed status.
|
// 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.";
|
const message = err?.response?.data?.message ?? "Upload failed.";
|
||||||
patchJobs([job.id], { status: "failed", progress: 100, error: message });
|
patchJobs([job.id], { status: "failed", progress: 100, error: message });
|
||||||
|
return { id: job.id, name: job.name, status: "failed", error: message };
|
||||||
}
|
}
|
||||||
}, [patchJobs]);
|
}, [patchJobs]);
|
||||||
|
|
||||||
@@ -91,29 +103,43 @@ export function UploadQueueProvider({ children }) {
|
|||||||
const ids = batchJobs.map((j) => j.id);
|
const ids = batchJobs.map((j) => j.id);
|
||||||
patchJobs(ids, { status: "uploading", progress: 0 });
|
patchJobs(ids, { status: "uploading", progress: 0 });
|
||||||
|
|
||||||
|
const results = new Array(batchJobs.length);
|
||||||
let next = 0;
|
let next = 0;
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
while (next < batchJobs.length) {
|
while (next < batchJobs.length) {
|
||||||
await uploadOne(batchJobs[next++]);
|
const i = next++;
|
||||||
|
results[i] = await uploadOne(batchJobs[i]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from({ length: Math.min(MAX_CONCURRENT, batchJobs.length) }, worker)
|
Array.from({ length: Math.min(MAX_CONCURRENT, batchJobs.length) }, worker)
|
||||||
);
|
);
|
||||||
|
|
||||||
onSettledRef.current.get(batchId)?.();
|
onSettledRef.current.get(batchId)?.(results);
|
||||||
onSettledRef.current.delete(batchId);
|
onSettledRef.current.delete(batchId);
|
||||||
}, [patchJobs, uploadOne]);
|
}, [patchJobs, uploadOne]);
|
||||||
|
|
||||||
// files: File[]; meta: { is_public, storage_provider, createdBy }
|
// items: (File | { file: File, thumbnail?: File })[] — a plain File is what
|
||||||
const addBatch = useCallback((files, meta, { onSettled } = {}) => {
|
// 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 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);
|
const { ok, reason } = validateAssetFile(file);
|
||||||
return {
|
return {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
batchId,
|
batchId,
|
||||||
|
source,
|
||||||
file,
|
file,
|
||||||
|
thumbnail,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
mime: file.type,
|
mime: file.type,
|
||||||
@@ -153,8 +179,13 @@ export function UploadQueueProvider({ children }) {
|
|||||||
setJobs((prev) => prev.filter((j) => j.id !== jobId || j.status === "uploading"));
|
setJobs((prev) => prev.filter((j) => j.id !== jobId || j.status === "uploading"));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearFinished = useCallback(() => {
|
// source: restricts the clear to that source's finished jobs only, so
|
||||||
setJobs((prev) => prev.filter((j) => j.status === "uploading" || j.status === "queued"));
|
// 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 (
|
return (
|
||||||
|
|||||||
+16
-16
@@ -105,16 +105,16 @@ export const ACTION_CONFIG = {
|
|||||||
add_user_to_group: { label: "Added User to Group", group: "grp" },
|
add_user_to_group: { label: "Added User to Group", group: "grp" },
|
||||||
remove_user_from_group:{ label: "Removed User from Group", group: "grp" },
|
remove_user_from_group:{ label: "Removed User from Group", group: "grp" },
|
||||||
|
|
||||||
// ── Tier Plans ──────────────────────────────────────────────────────────────
|
// ── Subscriptions ───────────────────────────────────────────────────────────
|
||||||
create_tier_plan: { label: "Created Tier Plan", group: "commerce" },
|
create_tier_plan: { label: "Created Subscription", group: "commerce" },
|
||||||
update_tier_plan: { label: "Updated Tier Plan", group: "commerce" },
|
update_tier_plan: { label: "Updated Subscription", group: "commerce" },
|
||||||
archive_tier_plan: { label: "Archived Tier Plan", group: "commerce" },
|
archive_tier_plan: { label: "Archived Subscription", group: "commerce" },
|
||||||
restore_tier_plan: { label: "Restored Tier Plan", group: "commerce" },
|
restore_tier_plan: { label: "Restored Subscription", group: "commerce" },
|
||||||
bulk_archive_tier_plans: { label: "Bulk Archived Tier Plans", group: "commerce" },
|
bulk_archive_tier_plans: { label: "Bulk Archived Subscriptions", group: "commerce" },
|
||||||
bulk_restore_tier_plans: { label: "Bulk Restored Tier Plans", group: "commerce" },
|
bulk_restore_tier_plans: { label: "Bulk Restored Subscriptions", group: "commerce" },
|
||||||
sync_plan_courses: { label: "Synced Plan Courses", group: "commerce" },
|
sync_plan_courses: { label: "Synced Plan Courses", group: "commerce" },
|
||||||
grant_tier: { label: "Granted Tier", group: "success" },
|
grant_tier: { label: "Granted Subscription", group: "success" },
|
||||||
revoke_tier: { label: "Revoked Tier", group: "danger" },
|
revoke_tier: { label: "Revoked Subscription", group: "danger" },
|
||||||
|
|
||||||
// ── Products & Categories ───────────────────────────────────────────────────
|
// ── Products & Categories ───────────────────────────────────────────────────
|
||||||
upsert_course_product: { label: "Set Course Product", group: "commerce" },
|
upsert_course_product: { label: "Set Course Product", group: "commerce" },
|
||||||
@@ -125,13 +125,13 @@ export const ACTION_CONFIG = {
|
|||||||
archive_category: { label: "Archived Category", group: "commerce" },
|
archive_category: { label: "Archived Category", group: "commerce" },
|
||||||
restore_category: { label: "Restored Category", group: "commerce" },
|
restore_category: { label: "Restored Category", group: "commerce" },
|
||||||
|
|
||||||
// ── Assets ──────────────────────────────────────────────────────────────────
|
// ── Files ───────────────────────────────────────────────────────────────────
|
||||||
upload_asset: { label: "Uploaded Asset", group: "content" },
|
upload_asset: { label: "Uploaded File", group: "content" },
|
||||||
update_asset: { label: "Updated Asset", group: "content" },
|
update_asset: { label: "Updated File", group: "content" },
|
||||||
archive_asset: { label: "Archived Asset", group: "content" },
|
archive_asset: { label: "Archived File", group: "content" },
|
||||||
restore_asset: { label: "Restored Asset", group: "content" },
|
restore_asset: { label: "Restored File", group: "content" },
|
||||||
bulk_archive_assets:{ label: "Bulk Archived Assets", group: "content" },
|
bulk_archive_assets:{ label: "Bulk Archived Files", group: "content" },
|
||||||
bulk_restore_assets:{ label: "Bulk Restored Assets", group: "content" },
|
bulk_restore_assets:{ label: "Bulk Restored Files", group: "content" },
|
||||||
|
|
||||||
// ── Advertisements ──────────────────────────────────────────────────────────
|
// ── Advertisements ──────────────────────────────────────────────────────────
|
||||||
create_advertisement: { label: "Created Ad", group: "content" },
|
create_advertisement: { label: "Created Ad", group: "content" },
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ export const ADMIN_SECTIONS = [
|
|||||||
id: "section-resources",
|
id: "section-resources",
|
||||||
tab: "Resource Management",
|
tab: "Resource Management",
|
||||||
title: "Resource Management",
|
title: "Resource Management",
|
||||||
description: "It includes assets management and tier plans.",
|
description: "It includes files management and subscriptions.",
|
||||||
tiles: [
|
tiles: [
|
||||||
{ key: "assets", label: "Assets", icon: FolderOpen, link: "/admin/assets" },
|
{ key: "assets", label: "Files", icon: FolderOpen, link: "/admin/assets" },
|
||||||
{ key: "tiers", label: "Tier Plans", icon: ShieldCheck, link: "/admin/tiers/plans" },
|
{ 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." },
|
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." },
|
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." },
|
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.
|
// 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: "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: "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: "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(
|
export const TARGET_TYPE_MAP = Object.fromEntries(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
export const PLACEMENTS = [
|
export const PLACEMENTS = [
|
||||||
{ key: "dashboard.hero", format: "hero", page: "dashboard", pageLabel: "Dashboard", slotLabel: "Hero (top of page)" },
|
{ 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)" },
|
{ 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 = {
|
const exportConfig = {
|
||||||
allData: assets,
|
allData: assets,
|
||||||
attributes,
|
attributes,
|
||||||
filename: `${getTimestamp()}_ArchivedAssets`,
|
filename: `${getTimestamp()}_ArchivedFiles`,
|
||||||
sheetName: "Archived Assets",
|
sheetName: "Archived Files",
|
||||||
generatedBy: formatGeneratedBy(currentUser),
|
generatedBy: formatGeneratedBy(currentUser),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ export default function ArchivedAssetsTable() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
title="Archived Assets"
|
title="Archived Files"
|
||||||
data={assets}
|
data={assets}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
@@ -125,8 +125,8 @@ export default function ArchivedAssetsTable() {
|
|||||||
columnPinning={columnPinning}
|
columnPinning={columnPinning}
|
||||||
toolbarActions={toolbarActions}
|
toolbarActions={toolbarActions}
|
||||||
selectionActions={selectionActions}
|
selectionActions={selectionActions}
|
||||||
recordLabel="archived asset"
|
recordLabel="archived file"
|
||||||
emptyMessage="No archived assets found."
|
emptyMessage="No archived files found."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Single restore ── */}
|
{/* ── Single restore ── */}
|
||||||
@@ -134,7 +134,7 @@ export default function ArchivedAssetsTable() {
|
|||||||
open={!!restoreTarget}
|
open={!!restoreTarget}
|
||||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||||
entity={restoreTarget}
|
entity={restoreTarget}
|
||||||
entityLabel="Asset"
|
entityLabel="File"
|
||||||
getName={(a) => a?.display_name ?? a?.original_name}
|
getName={(a) => a?.display_name ?? a?.original_name}
|
||||||
onRestore={(a) => restoreAsset(a?.asset_id)}
|
onRestore={(a) => restoreAsset(a?.asset_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -146,7 +146,7 @@ export default function ArchivedAssetsTable() {
|
|||||||
open={!!restoreIds}
|
open={!!restoreIds}
|
||||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||||
ids={restoreIds ?? []}
|
ids={restoreIds ?? []}
|
||||||
entityLabel="Asset"
|
entityLabel="File"
|
||||||
onRestore={(ids) => restoreAssets(ids)}
|
onRestore={(ids) => restoreAssets(ids)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleRestoreSuccess}
|
onSuccess={handleRestoreSuccess}
|
||||||
@@ -157,7 +157,7 @@ export default function ArchivedAssetsTable() {
|
|||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
onOpenChange={(v) => !v && setDeleteTarget(null)}
|
||||||
entity={deleteTarget}
|
entity={deleteTarget}
|
||||||
entityLabel="Asset"
|
entityLabel="File"
|
||||||
getName={(a) => a?.display_name ?? a?.original_name}
|
getName={(a) => a?.display_name ?? a?.original_name}
|
||||||
onDelete={(a) => permanentlyDeleteAsset(a?.asset_id)}
|
onDelete={(a) => permanentlyDeleteAsset(a?.asset_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -169,7 +169,7 @@ export default function ArchivedAssetsTable() {
|
|||||||
open={!!deleteIds}
|
open={!!deleteIds}
|
||||||
onOpenChange={(v) => !v && setDeleteIds(null)}
|
onOpenChange={(v) => !v && setDeleteIds(null)}
|
||||||
ids={deleteIds ?? []}
|
ids={deleteIds ?? []}
|
||||||
entityLabel="Asset"
|
entityLabel="File"
|
||||||
onDelete={(ids) => permanentlyDeleteAssets(ids)}
|
onDelete={(ids) => permanentlyDeleteAssets(ids)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleDeleteSuccess}
|
onSuccess={handleDeleteSuccess}
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export default function AssetsTable() {
|
|||||||
const exportConfig = {
|
const exportConfig = {
|
||||||
allData: assets,
|
allData: assets,
|
||||||
attributes,
|
attributes,
|
||||||
filename: `${getTimestamp()}_Assets`,
|
filename: `${getTimestamp()}_Files`,
|
||||||
sheetName: "Assets",
|
sheetName: "Files",
|
||||||
generatedBy: formatGeneratedBy(currentUser),
|
generatedBy: formatGeneratedBy(currentUser),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ export default function AssetsTable() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataTable
|
<DataTable
|
||||||
title="Assets"
|
title="Files"
|
||||||
data={assets}
|
data={assets}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
attributes={attributes}
|
attributes={attributes}
|
||||||
@@ -117,8 +117,8 @@ export default function AssetsTable() {
|
|||||||
columnPinning={columnPinning}
|
columnPinning={columnPinning}
|
||||||
toolbarActions={toolbarActions}
|
toolbarActions={toolbarActions}
|
||||||
selectionActions={selectionActions}
|
selectionActions={selectionActions}
|
||||||
recordLabel="asset"
|
recordLabel="file"
|
||||||
emptyMessage="No assets match the current filters."
|
emptyMessage="No files match the current filters."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Single archive ── */}
|
{/* ── Single archive ── */}
|
||||||
@@ -126,7 +126,7 @@ export default function AssetsTable() {
|
|||||||
open={!!archiveTarget}
|
open={!!archiveTarget}
|
||||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||||
entity={archiveTarget}
|
entity={archiveTarget}
|
||||||
entityLabel="Asset"
|
entityLabel="File"
|
||||||
getName={(a) => a?.display_name ?? a?.original_name}
|
getName={(a) => a?.display_name ?? a?.original_name}
|
||||||
onArchive={(a) => archiveAsset(a?.asset_id)}
|
onArchive={(a) => archiveAsset(a?.asset_id)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -138,7 +138,7 @@ export default function AssetsTable() {
|
|||||||
open={!!archiveIds}
|
open={!!archiveIds}
|
||||||
onOpenChange={(v) => !v && setArchiveIds(null)}
|
onOpenChange={(v) => !v && setArchiveIds(null)}
|
||||||
ids={archiveIds ?? []}
|
ids={archiveIds ?? []}
|
||||||
entityLabel="Asset"
|
entityLabel="File"
|
||||||
onArchive={(ids) => archiveAssets(ids)}
|
onArchive={(ids) => archiveAssets(ids)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSuccess={handleArchiveSuccess}
|
onSuccess={handleArchiveSuccess}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||||||
|
|
||||||
const TRIGGER_OPTIONS = [
|
const TRIGGER_OPTIONS = [
|
||||||
{ value: "auth", label: "Auth (registration / login)" },
|
{ 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: "course", label: "Course (lessons / quizzes)" },
|
||||||
{ value: "profile", label: "Profile completion" },
|
{ value: "profile", label: "Profile completion" },
|
||||||
{ value: "social", label: "Social (referrals / community)" },
|
{ 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,
|
allData: plans,
|
||||||
attributes: planAttributes,
|
attributes: planAttributes,
|
||||||
filename: `${getTimestamp()}_ArchivedTierPlans`,
|
filename: `${getTimestamp()}_ArchivedTierPlans`,
|
||||||
sheetName: "Archived Tier Plans",
|
sheetName: "Archived Subscriptions",
|
||||||
generatedBy: formatGeneratedBy(currentUser),
|
generatedBy: formatGeneratedBy(currentUser),
|
||||||
}), [plans, planAttributes, currentUser]);
|
}), [plans, planAttributes, currentUser]);
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default function TierPlansTable() {
|
|||||||
allData: plans,
|
allData: plans,
|
||||||
attributes: planAttributes,
|
attributes: planAttributes,
|
||||||
filename: `${getTimestamp()}_TierPlans`,
|
filename: `${getTimestamp()}_TierPlans`,
|
||||||
sheetName: "Tier Plans",
|
sheetName: "Subscriptions",
|
||||||
generatedBy: formatGeneratedBy(currentUser),
|
generatedBy: formatGeneratedBy(currentUser),
|
||||||
}), [plans, planAttributes, 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">
|
<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" />
|
<Layers className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<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">
|
<Link to="/admin/tiers/categories/add" className="underline text-primary font-medium">
|
||||||
Add a tier category
|
Add a subscription category
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
before creating plans.
|
before creating plans.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DataTable
|
<DataTable
|
||||||
title="Tier Plans"
|
title="Subscriptions"
|
||||||
data={plans}
|
data={plans}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
attributes={planAttributes}
|
attributes={planAttributes}
|
||||||
@@ -138,7 +138,7 @@ export default function TierPlansTable() {
|
|||||||
toolbarActions={toolbarActions}
|
toolbarActions={toolbarActions}
|
||||||
selectionActions={selectionActions}
|
selectionActions={selectionActions}
|
||||||
recordLabel="plan"
|
recordLabel="plan"
|
||||||
emptyMessage="No tier plans found."
|
emptyMessage="No subscriptions found."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Single archive — always force-revokes current subscribers' access (no refund), handled server-side */}
|
{/* Single archive — always force-revokes current subscribers' access (no refund), handled server-side */}
|
||||||
|
|||||||
@@ -46,6 +46,6 @@ export function buildDataColumns(attributes, rowActions) {
|
|||||||
return [
|
return [
|
||||||
buildSelectionColumn(),
|
buildSelectionColumn(),
|
||||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Asset Actions" }),
|
buildRowActionsColumn(rowActions, { dropdownLabel: "File Actions" }),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,6 @@ export function buildDataColumns(attributes, rowActions) {
|
|||||||
return [
|
return [
|
||||||
buildSelectionColumn(),
|
buildSelectionColumn(),
|
||||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
...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",
|
key: "add-asset",
|
||||||
type: "button",
|
type: "button",
|
||||||
icon: <Plus className="h-3.5 w-3.5" />,
|
icon: <Plus className="h-3.5 w-3.5" />,
|
||||||
label: "Add Asset",
|
label: "Add File",
|
||||||
variant: "default",
|
variant: "default",
|
||||||
className: "text-primary-foreground",
|
className: "text-primary-foreground",
|
||||||
onClick: () => navigate("add"),
|
onClick: () => navigate("add"),
|
||||||
@@ -60,7 +60,7 @@ export function buildToolbarActions({ fetchAssets, pagination, exportConfig, nav
|
|||||||
key: "archived-users",
|
key: "archived-users",
|
||||||
type: "button",
|
type: "button",
|
||||||
icon: <Archive className="h-3.5 w-3.5" />,
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
label: "Archived Assets",
|
label: "Archived Files",
|
||||||
variant: "secondary",
|
variant: "secondary",
|
||||||
className: "border border-border",
|
className: "border border-border",
|
||||||
onClick: () => navigate("/admin/assets/archived"),
|
onClick: () => navigate("/admin/assets/archived"),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function buildToolbarActions({
|
|||||||
{
|
{
|
||||||
key: "categories",
|
key: "categories",
|
||||||
type: "button",
|
type: "button",
|
||||||
label: "Tier Categories",
|
label: "Subscription Categories",
|
||||||
icon: <Layers className="h-3.5 w-3.5" />,
|
icon: <Layers className="h-3.5 w-3.5" />,
|
||||||
variant: "outline",
|
variant: "outline",
|
||||||
onClick: () => navigate("/admin/tiers/categories"),
|
onClick: () => navigate("/admin/tiers/categories"),
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const schema = z.object({
|
|||||||
|
|
||||||
// ─── Steps ────────────────────────────────────────────────────────────────────
|
// ─── Steps ────────────────────────────────────────────────────────────────────
|
||||||
const ALL_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: "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: "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." },
|
{ id: "review", label: "Review", icon: Check, description: "Confirm everything before creating this ad." },
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export default function EditAdvertisement() {
|
|||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
|
<SectionCard title="Image" description="Choose an existing file from File Management.">
|
||||||
{selectedAsset ? (
|
{selectedAsset ? (
|
||||||
<div
|
<div
|
||||||
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
|
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 { useForm, Controller } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react";
|
import { ArrowLeft, ArrowRight, UploadCloud, X, FileVideo, FileText, Image, FileAudio } from "lucide-react";
|
||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { useUploadQueue } from "@/contexts/UploadQueueContext";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { MAX_ASSET_FILE_SIZE_SINGLE, MAX_ASSET_FILE_SIZE_SINGLE_LABEL } from "@/utils/assetUpload.util";
|
import { MAX_ASSET_FILE_SIZE_SINGLE, MAX_ASSET_FILE_SIZE_SINGLE_LABEL } from "@/utils/assetUpload.util";
|
||||||
import { formatFileSize } from "@/utils/format.util";
|
import { formatFileSize } from "@/utils/format.util";
|
||||||
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
import { useUnsavedChangesGuard } from "@/hooks/useUnsavedChangesGuard";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -39,8 +37,6 @@ function resolveFileType(mimeType = "") {
|
|||||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
display_name: z.string().min(1, "Display name is required."),
|
|
||||||
description: z.string().optional(),
|
|
||||||
is_public: z.enum(["true", "false"]),
|
is_public: z.enum(["true", "false"]),
|
||||||
storage_provider: z.enum(["chibisafe", "local", "s3"]),
|
storage_provider: z.enum(["chibisafe", "local", "s3"]),
|
||||||
});
|
});
|
||||||
@@ -119,16 +115,15 @@ function FieldError({ message }) {
|
|||||||
|
|
||||||
export default function AddAsset() {
|
export default function AddAsset() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { uploadAsset, loading } = useAssets();
|
const { fetchAssets } = useAssets();
|
||||||
|
const { addBatch } = useUploadQueue();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const fileRef = useRef(null);
|
const fileRef = useRef(null);
|
||||||
const thumbnailRef = useRef(null);
|
const thumbnailRef = useRef(null);
|
||||||
const [thumbKey, setThumbKey] = useState(0);
|
const [thumbKey, setThumbKey] = useState(0);
|
||||||
const [progress, setProgress] = useState(null); // { phase: 'uploading'|'processing'|'done'|'error', pct } | null
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
|
||||||
control,
|
control,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setValue,
|
setValue,
|
||||||
@@ -139,8 +134,6 @@ export default function AddAsset() {
|
|||||||
} = useForm({
|
} = useForm({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
display_name: "",
|
|
||||||
description: "",
|
|
||||||
is_public: "false",
|
is_public: "false",
|
||||||
storage_provider: "s3", // ← changed from "chibisafe"
|
storage_provider: "s3", // ← changed from "chibisafe"
|
||||||
},
|
},
|
||||||
@@ -164,7 +157,6 @@ export default function AddAsset() {
|
|||||||
}
|
}
|
||||||
fileRef.current = f;
|
fileRef.current = f;
|
||||||
setValue("_file", f);
|
setValue("_file", f);
|
||||||
if (!watch("display_name")) setValue("display_name", f.name);
|
|
||||||
clearErrors("_file");
|
clearErrors("_file");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,40 +166,46 @@ export default function AddAsset() {
|
|||||||
clearErrors("_thumbnail");
|
clearErrors("_thumbnail");
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSubmit = async (data) => {
|
// Fire-and-forget: the job goes on the same global UploadQueueContext the
|
||||||
let hasFileError = false;
|
// 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) {
|
if (!fileRef.current) {
|
||||||
setError("_file", { message: "A file is required." });
|
setError("_file", { message: "A file is required." });
|
||||||
hasFileError = true;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFileError) return;
|
const fileName = fileRef.current.name;
|
||||||
|
const uploadPromise = new Promise((resolve, reject) => {
|
||||||
setProgress({ phase: "uploading", pct: 0 });
|
addBatch(
|
||||||
const result = await uploadAsset({
|
[{ file: fileRef.current, thumbnail: thumbnailRef.current ?? undefined }],
|
||||||
file: fileRef.current,
|
{
|
||||||
thumbnail: thumbnailRef.current ?? undefined,
|
|
||||||
display_name: data.display_name,
|
|
||||||
description: data.description ?? "",
|
|
||||||
file_type: fileType,
|
|
||||||
is_public: data.is_public === "true",
|
is_public: data.is_public === "true",
|
||||||
storage_provider: data.storage_provider,
|
storage_provider: data.storage_provider,
|
||||||
createdBy: user?.user_id,
|
createdBy: user?.user_id,
|
||||||
onProgress: setProgress,
|
},
|
||||||
|
{
|
||||||
|
source: "single",
|
||||||
|
onSettled: ([result]) => {
|
||||||
|
fetchAssets({ force: true });
|
||||||
|
if (result?.status === "uploaded") resolve(result);
|
||||||
|
else reject(new Error(result?.error || "Upload failed."));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
setProgress(null);
|
|
||||||
|
|
||||||
if (result) { bypassOnce(); navigate("/admin/assets"); }
|
toast.promise(uploadPromise, {
|
||||||
|
loading: `Uploading ${fileName}…`,
|
||||||
|
success: (result) => `${result.name} uploaded successfully.`,
|
||||||
|
error: (err) => err.message || "Upload failed.",
|
||||||
|
});
|
||||||
|
|
||||||
|
bypassOnce();
|
||||||
|
navigate("/admin/assets");
|
||||||
};
|
};
|
||||||
|
|
||||||
const progressLabel = {
|
|
||||||
uploading: "Uploading…",
|
|
||||||
processing: "Processing…",
|
|
||||||
done: "Done.",
|
|
||||||
error: "Upload failed.",
|
|
||||||
}[progress?.phase];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
<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" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">Add Asset</h1>
|
<h1 className="text-xl font-semibold">Add File</h1>
|
||||||
<p className="text-sm text-muted-foreground">Upload a new file to the asset library.</p>
|
<p className="text-sm text-muted-foreground">Upload a new file to the file library.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -236,7 +234,6 @@ export default function AddAsset() {
|
|||||||
onClear={() => {
|
onClear={() => {
|
||||||
fileRef.current = null;
|
fileRef.current = null;
|
||||||
setValue("_file", null);
|
setValue("_file", null);
|
||||||
setValue("display_name", "");
|
|
||||||
clearErrors("_file");
|
clearErrors("_file");
|
||||||
}}
|
}}
|
||||||
error={errors._file?.message}
|
error={errors._file?.message}
|
||||||
@@ -277,30 +274,6 @@ export default function AddAsset() {
|
|||||||
</div>
|
</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) ── */}
|
{/* ── File Type · Access · Storage (one row) ── */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
|
||||||
@@ -355,29 +328,12 @@ export default function AddAsset() {
|
|||||||
|
|
||||||
</div>
|
</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 ── */}
|
{/* ── Actions ── */}
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => navigate("/admin/assets")}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate("/admin/assets")}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit">
|
||||||
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
|
||||||
Upload Asset
|
Upload Asset
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,9 +122,14 @@ function FileRow({ job, onRetry, onRemove }) {
|
|||||||
export default function AddAssetsBulk() {
|
export default function AddAssetsBulk() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { fetchAssets } = useAssets();
|
const { fetchAssets } = useAssets();
|
||||||
const { jobs, addBatch, retryJob, removeJob, clearFinished } = useUploadQueue();
|
const { jobs: allJobs, addBatch, retryJob, removeJob, clearFinished } = useUploadQueue();
|
||||||
const { user } = useAuth();
|
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");
|
const [isPublic, setIsPublic] = useState("false");
|
||||||
// TEMPORARY: locked to S3 (see zrok tunnel note; revert after VPS migration)
|
// TEMPORARY: locked to S3 (see zrok tunnel note; revert after VPS migration)
|
||||||
const storageProvider = "s3";
|
const storageProvider = "s3";
|
||||||
@@ -140,6 +145,7 @@ export default function AddAssetsBulk() {
|
|||||||
createdBy: user?.user_id,
|
createdBy: user?.user_id,
|
||||||
}, {
|
}, {
|
||||||
onSettled: () => fetchAssets({ force: true }),
|
onSettled: () => fetchAssets({ force: true }),
|
||||||
|
source: "bulk",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,9 +158,9 @@ export default function AddAssetsBulk() {
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -195,7 +201,7 @@ export default function AddAssetsBulk() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Files</span>
|
<span className="text-sm font-medium text-muted-foreground">Files</span>
|
||||||
{(done > 0 || failed > 0) && (
|
{(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
|
<Trash2 className="h-3.5 w-3.5 mr-1" /> Clear finished
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import ArchivedAssetsTable from "../../components/assets/ArchivedAssetsTable";
|
|||||||
export default function ArchivedAssetList() {
|
export default function ArchivedAssetList() {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
{ label: "Assets", to: `/admin/assets` },
|
{ label: "Files", to: `/admin/assets` },
|
||||||
{ label: "Archived" },
|
{ label: "Archived" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import AssetsTable from "../../components/assets/AssetsTable";
|
|||||||
export default function AssetList() {
|
export default function AssetList() {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
{ label: "Assets" },
|
{ label: "Files" },
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export default function EditAsset() {
|
|||||||
if (!asset) {
|
if (!asset) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto px-4 py-6">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ export default function EditAsset() {
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<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">
|
<p className="text-sm text-muted-foreground truncate max-w-sm">
|
||||||
{asset.original_name}
|
{asset.original_name}
|
||||||
</p>
|
</p>
|
||||||
@@ -287,7 +287,7 @@ export default function EditAsset() {
|
|||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="display_name"
|
id="display_name"
|
||||||
placeholder="Friendly name for this asset"
|
placeholder="Friendly name for this file"
|
||||||
{...register("display_name")}
|
{...register("display_name")}
|
||||||
/>
|
/>
|
||||||
<FieldError message={errors.display_name?.message} />
|
<FieldError message={errors.display_name?.message} />
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default function ViewAudioAsset() {
|
|||||||
if (notFound) {
|
if (notFound) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
<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>
|
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function ViewDocumentAsset() {
|
|||||||
if (notFound) {
|
if (notFound) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
<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>
|
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export default function ViewImageAsset() {
|
|||||||
if (notFound) {
|
if (notFound) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
<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>
|
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function ViewVideoAsset() {
|
|||||||
if (notFound) {
|
if (notFound) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
<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>
|
<Button variant="outline" onClick={() => navigate("/admin/assets")}>Go Back</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -593,7 +593,7 @@ export default function AddCourse() {
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
<ImagePlus className="h-3 w-3 mr-1" />
|
<ImagePlus className="h-3 w-3 mr-1" />
|
||||||
{badgeImageUrl ? "Change" : "Pick from assets"}
|
{badgeImageUrl ? "Change" : "Pick from files"}
|
||||||
</Button>
|
</Button>
|
||||||
{badgeImageUrl && (
|
{badgeImageUrl && (
|
||||||
<Button
|
<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">
|
{/* <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" />
|
<Layers className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
|
||||||
<p className="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.{" "}
|
<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">
|
<Link to="/admin/tiers/plans" className="text-primary hover:underline font-medium">
|
||||||
Manage Tier Plans
|
Manage Subscriptions
|
||||||
</Link>
|
</Link>
|
||||||
{" "}· Units and Lessons now run independently — build them once in the{" "}
|
{" "}· Units and Lessons now run independently — build them once in the{" "}
|
||||||
<Link to="/admin/units" className="text-primary hover:underline font-medium">
|
<Link to="/admin/units" className="text-primary hover:underline font-medium">
|
||||||
|
|||||||
@@ -988,7 +988,7 @@ export default function EditCourse() {
|
|||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
>
|
>
|
||||||
<ImagePlus className="h-3 w-3 mr-1" />
|
<ImagePlus className="h-3 w-3 mr-1" />
|
||||||
{badgeImageUrl ? "Change" : "Pick from assets"}
|
{badgeImageUrl ? "Change" : "Pick from files"}
|
||||||
</Button>
|
</Button>
|
||||||
{badgeImageUrl && (
|
{badgeImageUrl && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ function StepDetails({ register, errors, control, setValue, tierCategories }) {
|
|||||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="No tier gate" />
|
<SelectValue placeholder="No subscription gate" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tierCategories.map((c) => (
|
{tierCategories.map((c) => (
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export default function EditLesson() {
|
|||||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="No tier gate" />
|
<SelectValue placeholder="No subscription gate" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tierCategories.map((c) => (
|
{tierCategories.map((c) => (
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ export default function AddUnit() {
|
|||||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="No tier gate" />
|
<SelectValue placeholder="No subscription gate" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tierCategories.map((c) => (
|
{tierCategories.map((c) => (
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export default function EditUnit() {
|
|||||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="No tier gate" />
|
<SelectValue placeholder="No subscription gate" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tierCategories.map((c) => (
|
{tierCategories.map((c) => (
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function StepLesson({ register, errors, control, setValue, tierCategories }) {
|
|||||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="No tier gate" />
|
<SelectValue placeholder="No subscription gate" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tierCategories.map((c) => (
|
{tierCategories.map((c) => (
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default function EditLibraryLesson() {
|
|||||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="No tier gate" />
|
<SelectValue placeholder="No subscription gate" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tierCategories.map((c) => (
|
{tierCategories.map((c) => (
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useNavigate, useParams, Link } from "react-router-dom";
|
import { useNavigate, useParams, Link } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
House, Pencil, LayoutTemplate, Clock, BookCheck,
|
House, Pencil, LayoutTemplate, Clock, BookCheck, Link2, Unlink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
import { useLibrary } from "@/contexts/AdminLibraryContext";
|
||||||
import { PageMeta } from "@/contexts/MetadataContext";
|
import { PageMeta } from "@/contexts/MetadataContext";
|
||||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
|
import AttachLessonToUnitsDialog from "../../../components/library/AttachLessonToUnitsDialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
@@ -16,8 +17,9 @@ import { PreviewChrome, PreviewContent } from "../../../components/courses/Lesso
|
|||||||
export default function ViewLibraryLesson() {
|
export default function ViewLibraryLesson() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { lessonId } = useParams();
|
const { lessonId } = useParams();
|
||||||
const { fetchLesson, lesson, loading } = useLibrary();
|
const { fetchLesson, lesson, loading, attachLessonToUnits, detachLessonFromUnit } = useLibrary();
|
||||||
const [initializing, setInitializing] = useState(true);
|
const [initializing, setInitializing] = useState(true);
|
||||||
|
const [attachOpen, setAttachOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -36,6 +38,16 @@ export default function ViewLibraryLesson() {
|
|||||||
const blocks = lesson?.page?.blocks ?? [];
|
const blocks = lesson?.page?.blocks ?? [];
|
||||||
const objectives = lesson?.objectives ?? [];
|
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) {
|
if (initializing) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -64,6 +76,23 @@ export default function ViewLibraryLesson() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 shrink-0">
|
<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`)}>
|
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/lessons/${lessonId}/edit`)}>
|
||||||
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
|
<Pencil className="h-3.5 w-3.5 mr-1.5" /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -99,7 +128,7 @@ export default function ViewLibraryLesson() {
|
|||||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
<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>
|
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Subscription</p>
|
||||||
<Badge variant={lesson?.subscription ? "outline" : "secondary"} className="capitalize">
|
<Badge variant={lesson?.subscription ? "outline" : "secondary"} className="capitalize">
|
||||||
{lesson?.subscription ?? "No tier gate"}
|
{lesson?.subscription ?? "No subscription gate"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,6 +188,14 @@ export default function ViewLibraryLesson() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AttachLessonToUnitsDialog
|
||||||
|
open={attachOpen}
|
||||||
|
onOpenChange={setAttachOpen}
|
||||||
|
attachedUnitIds={units.map((u) => u.unit_id)}
|
||||||
|
onAttach={handleAttach}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function StepUnit({ register, errors, control, setValue, tierCategories }) {
|
|||||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="No tier gate" />
|
<SelectValue placeholder="No subscription gate" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tierCategories.map((c) => (
|
{tierCategories.map((c) => (
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export default function EditLibraryUnit() {
|
|||||||
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
onValueChange={(val) => setValue("subscription", val, { shouldDirty: true })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="No tier gate" />
|
<SelectValue placeholder="No subscription gate" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{tierCategories.map((c) => (
|
{tierCategories.map((c) => (
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export default function ViewLibraryUnit() {
|
|||||||
<div className="bg-muted/60 rounded-lg p-3 space-y-1">
|
<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>
|
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">Subscription</p>
|
||||||
<Badge variant={unit?.subscription ? "outline" : "secondary"} className="capitalize">
|
<Badge variant={unit?.subscription ? "outline" : "secondary"} className="capitalize">
|
||||||
{unit?.subscription ?? "No tier gate"}
|
{unit?.subscription ?? "No subscription gate"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const BUNDLE_TYPES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const schema = z.object({
|
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."),
|
label: z.string().min(1, "Label is required."),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
features: z.array(z.object({ text: z.string().min(1, "Feature cannot be empty.") })).default([]),
|
features: z.array(z.object({ text: z.string().min(1, "Feature cannot be empty.") })).default([]),
|
||||||
@@ -335,7 +335,7 @@ export default function AddPlan() {
|
|||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">Add Plan</h1>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -345,17 +345,17 @@ export default function AddPlan() {
|
|||||||
|
|
||||||
{/* ── Step 0: Bundles & Details ── */}
|
{/* ── Step 0: Bundles & Details ── */}
|
||||||
{currentStep === 0 && (
|
{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">
|
<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 ? (
|
{catLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
|
||||||
<Spinner className="h-4 w-4" /> Loading categories…
|
<Spinner className="h-4 w-4" /> Loading categories…
|
||||||
</div>
|
</div>
|
||||||
) : categories.length === 0 ? (
|
) : categories.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground py-2">
|
<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>
|
<a href="/admin/tiers/categories/add" className="underline text-primary">Add one first.</a>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -364,7 +364,7 @@ export default function AddPlan() {
|
|||||||
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
|
onValueChange={(v) => setValue("tier_category_id", v, { shouldDirty: true })}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select tier category" />
|
<SelectValue placeholder="Select subscription category" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
@@ -560,7 +560,7 @@ export default function AddPlan() {
|
|||||||
<SectionCard title="Review" description="Confirm everything before creating this plan.">
|
<SectionCard title="Review" description="Confirm everything before creating this plan.">
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<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">
|
<p className="text-sm">
|
||||||
{selectedCategory ? `${selectedCategory.name} (${selectedCategory.slug})` : "—"}
|
{selectedCategory ? `${selectedCategory.name} (${selectedCategory.slug})` : "—"}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function ArchivedPlanList() {
|
|||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ 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: "Plans", to: "/admin/tiers/plans" },
|
||||||
{ label: "Archived" },
|
{ label: "Archived" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ function BadgePicker({ currentAsset, selectedAsset, onSelect, onClear }) {
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||||
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
|
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
|
||||||
{display ? "Change image" : "Pick from assets"}
|
{display ? "Change image" : "Pick from files"}
|
||||||
</Button>
|
</Button>
|
||||||
{display && (
|
{display && (
|
||||||
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={onClear}>
|
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={onClear}>
|
||||||
@@ -212,14 +212,14 @@ function EditTierCategoryInner({ isAdd }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-muted/60 min-h-full">
|
<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="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="w-full max-w-2xl mx-auto">
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
<AppBreadcrumb items={[
|
<AppBreadcrumb items={[
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ 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") },
|
{ label: isAdd ? "Add Category" : (category?.name ?? "Edit") },
|
||||||
]} />
|
]} />
|
||||||
</div>
|
</div>
|
||||||
@@ -229,9 +229,9 @@ function EditTierCategoryInner({ isAdd }) {
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,9 +268,9 @@ function EditTierCategoryInner({ isAdd }) {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="rank">Rank (ordering)</Label>
|
<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" />
|
<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 && (
|
{!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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function PaymentList() {
|
|||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Tiers", to: "/admin/tiers/plans" },
|
{ label: "Subscriptions", to: "/admin/tiers/plans" },
|
||||||
{ label: "Payments" },
|
{ label: "Payments" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function PlanList() {
|
|||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Tiers", to: "/admin/tiers/plans" },
|
{ label: "Subscriptions", to: "/admin/tiers/plans" },
|
||||||
{ label: "Plans" },
|
{ label: "Plans" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function SystemBadgeCard({ badge: initialBadge }) {
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setPickerOpen(true)}>
|
<Button type="button" variant="outline" size="sm" onClick={() => setPickerOpen(true)}>
|
||||||
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
|
<ImagePlus className="h-3.5 w-3.5 mr-1.5" />
|
||||||
{displayAsset ? "Change image" : "Pick from assets"}
|
{displayAsset ? "Change image" : "Pick from files"}
|
||||||
</Button>
|
</Button>
|
||||||
{displayAsset && (
|
{displayAsset && (
|
||||||
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={handleClear}>
|
<Button type="button" variant="ghost" size="sm" className="text-destructive hover:text-destructive" onClick={handleClear}>
|
||||||
|
|||||||
@@ -87,21 +87,21 @@ function TierCategoriesInner() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-muted/60 min-h-full">
|
<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="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="w-full max-w-2xl mx-auto">
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 mb-6">
|
<div className="flex flex-col gap-2 mb-6">
|
||||||
<AppBreadcrumb items={[
|
<AppBreadcrumb items={[
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Tiers", to: "/admin/tiers/plans" },
|
{ label: "Subscriptions", to: "/admin/tiers/plans" },
|
||||||
{ label: "Tier Categories" },
|
{ label: "Subscription Categories" },
|
||||||
]} />
|
]} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<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">
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
Define the tier levels available on the platform. Plans are built under each category.
|
Define the tier levels available on the platform. Plans are built under each category.
|
||||||
</p>
|
</p>
|
||||||
@@ -125,7 +125,7 @@ function TierCategoriesInner() {
|
|||||||
{loading && !categories.length ? (
|
{loading && !categories.length ? (
|
||||||
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
|
<div className="flex justify-center py-12"><Spinner className="h-5 w-5" /></div>
|
||||||
) : !categories.length ? (
|
) : !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">
|
<div className="space-y-3">
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
@@ -145,7 +145,7 @@ function TierCategoriesInner() {
|
|||||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
<Dialog open={!!deleteTarget} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Delete Tier Category</DialogTitle>
|
<DialogTitle>Delete Subscription Category</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Are you sure you want to delete{" "}
|
Are you sure you want to delete{" "}
|
||||||
<span className="font-semibold text-foreground">{deleteTarget?.name}</span>?
|
<span className="font-semibold text-foreground">{deleteTarget?.name}</span>?
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function UserTierList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-muted/60 min-h-full">
|
<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="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="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: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "Users", to: "/admin/users" },
|
{ label: "Users", to: "/admin/users" },
|
||||||
{ label: `User #${userId}`, to: `/admin/users/view/${userId}` },
|
{ label: `User #${userId}`, to: `/admin/users/view/${userId}` },
|
||||||
{ label: "Tiers" },
|
{ label: "Subscriptions" },
|
||||||
]} />
|
]} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,7 +130,7 @@ export default function UserTierList() {
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<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>
|
<p className="text-sm text-muted-foreground">Tier history for User #{userId}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +144,7 @@ export default function UserTierList() {
|
|||||||
{activeTier && (
|
{activeTier && (
|
||||||
<div className="mb-5 rounded-lg border bg-card p-5 flex items-center justify-between gap-4">
|
<div className="mb-5 rounded-lg border bg-card p-5 flex items-center justify-between gap-4">
|
||||||
<div className="space-y-1">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm px-3 py-0.5">{tierBadge(activeTier.tier)}</span>
|
<span className="text-sm px-3 py-0.5">{tierBadge(activeTier.tier)}</span>
|
||||||
{activeTier.expires_at && (
|
{activeTier.expires_at && (
|
||||||
@@ -169,9 +169,9 @@ export default function UserTierList() {
|
|||||||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-24 w-full" />)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SectionCard icon={BadgeCheck} title="Tier History">
|
<SectionCard icon={BadgeCheck} title="Subscription History">
|
||||||
{userTiers.length === 0 ? (
|
{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">
|
<div className="space-y-4">
|
||||||
{userTiers.map((t) => (
|
{userTiers.map((t) => (
|
||||||
@@ -206,11 +206,11 @@ export default function UserTierList() {
|
|||||||
<Dialog open={grantOpen} onOpenChange={setGrantOpen}>
|
<Dialog open={grantOpen} onOpenChange={setGrantOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Grant Tier</DialogTitle>
|
<DialogTitle>Grant Subscription</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label>Tier</Label>
|
<Label>Subscription</Label>
|
||||||
<Select
|
<Select
|
||||||
value={grantForm.tier}
|
value={grantForm.tier}
|
||||||
onValueChange={(v) => setGrantForm((p) => ({ ...p, tier: v, plan_id: "" }))}
|
onValueChange={(v) => setGrantForm((p) => ({ ...p, tier: v, plan_id: "" }))}
|
||||||
@@ -232,7 +232,7 @@ export default function UserTierList() {
|
|||||||
disabled={!filteredPlans.length}
|
disabled={!filteredPlans.length}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{filteredPlans.map((p) => (
|
{filteredPlans.map((p) => (
|
||||||
@@ -271,7 +271,7 @@ export default function UserTierList() {
|
|||||||
<AlertDialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}>
|
<AlertDialog open={!!revokeTarget} onOpenChange={(o) => !o && setRevokeTarget(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Revoke tier?</AlertDialogTitle>
|
<AlertDialogTitle>Revoke subscription?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
The user's <strong className="capitalize">{revokeTarget?.tier}</strong> tier will be revoked
|
The user's <strong className="capitalize">{revokeTarget?.tier}</strong> tier will be revoked
|
||||||
and they'll be automatically downgraded to Free.
|
and they'll be automatically downgraded to Free.
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export default function ViewPayment() {
|
|||||||
<SectionCard icon={BadgeCheck} title="Plan">
|
<SectionCard icon={BadgeCheck} title="Plan">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<InfoRow label="Label">{payment.plan.label}</InfoRow>
|
<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>; })()}
|
{(() => { const { cls, label } = resolveTierBadge(payment.plan.tier, tierMap); return <Badge className={`${cls} mt-0.5`}>{label}</Badge>; })()}
|
||||||
</InfoRow>
|
</InfoRow>
|
||||||
<InfoRow label="Duration">{payment.plan.duration_days} days</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">
|
<SectionCard icon={Tag} title="Plan Details">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<InfoRow label="Label">{plan.label}</InfoRow>
|
<InfoRow label="Label">{plan.label}</InfoRow>
|
||||||
<InfoRow label="Tier">
|
<InfoRow label="Subscription">
|
||||||
{(() => {
|
{(() => {
|
||||||
const { cls, label } = resolveTierBadge(plan.tier, tierMap);
|
const { cls, label } = resolveTierBadge(plan.tier, tierMap);
|
||||||
return <Badge className={`${cls} mt-0.5`}>{label}</Badge>;
|
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
|
<Zap className="size-4" /> View Available Plans
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -851,7 +851,7 @@ const UnitList = () => {
|
|||||||
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
||||||
<Zap className="size-4" /> View Available Plans
|
<Zap className="size-4" /> View Available Plans
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ const LockedContent = () => {
|
|||||||
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
<Button onClick={() => navigate('/plans')} className="gap-1.5">
|
||||||
<Zap className="size-4" /> View Available Plans
|
<Zap className="size-4" /> View Available Plans
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user