mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
Adjusted
This commit is contained in:
@@ -11,7 +11,7 @@ export default function DashboardGrid({ sections = [] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-muted/60 xs:h-full lg:h-screen">
|
<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 px-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">
|
||||||
|
|||||||
@@ -10,9 +10,20 @@ export function useAssets() {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Initial States ────────────────────────────────────────────────────────────
|
||||||
|
const PAGINATION_INIT = {
|
||||||
|
page: 1,
|
||||||
|
limit: 10,
|
||||||
|
totalRecords: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
hasPrevPage: false,
|
||||||
|
hasNextPage: false,
|
||||||
|
};
|
||||||
|
|
||||||
export function AssetsProvider({ children }) {
|
export function AssetsProvider({ children }) {
|
||||||
const [assets, setAssets] = useState([]);
|
const [assets, setAssets] = useState([]);
|
||||||
const [pagination, setPagination] = useState(null);
|
const [attributes, setAttributes] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
||||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
@@ -31,12 +42,23 @@ export function AssetsProvider({ children }) {
|
|||||||
|
|
||||||
// ─── GET /api/admin/assets ────────────────────────────────────────────────
|
// ─── GET /api/admin/assets ────────────────────────────────────────────────
|
||||||
const fetchAssets = useCallback(
|
const fetchAssets = useCallback(
|
||||||
(params = {}) =>
|
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.get("/admin/assets", { params });
|
const { data } = await api.get("/admin/assets", {
|
||||||
setAssets(res.data?.result?.data ?? []);
|
params: {
|
||||||
setPagination(res.data?.result?.pagination ?? null);
|
page, limit,
|
||||||
return res.data;
|
filters: filters.length ? JSON.stringify(filters) : undefined,
|
||||||
|
sort: sort.length ? JSON.stringify(sort) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = data?.data;
|
||||||
|
|
||||||
|
setAssets(result?.data ?? []);
|
||||||
|
setPagination(result?.pagination ?? PAGINATION_INIT);
|
||||||
|
setAttributes(result.attributes);
|
||||||
|
|
||||||
|
return data.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
@@ -46,16 +68,34 @@ export function AssetsProvider({ children }) {
|
|||||||
(assetId) =>
|
(assetId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.get(`/admin/assets/${assetId}`);
|
const res = await api.get(`/admin/assets/${assetId}`);
|
||||||
setSelectedAsset(res.data?.result?.data ?? null);
|
setSelectedAsset(res.data?.data?.data ?? null);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/admin/assets/archived ───────────────────────────────────────
|
||||||
|
const fetchArchivedAssets = useCallback(
|
||||||
|
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.get("/admin/assets/archived", {
|
||||||
|
params: {
|
||||||
|
page, limit,
|
||||||
|
filters: filters.length ? JSON.stringify(filters) : undefined,
|
||||||
|
sort: sort.length ? JSON.stringify(sort) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const final_data = data?.data;
|
||||||
|
|
||||||
|
setAssets(final_data?.data ?? []);
|
||||||
|
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
||||||
|
setAttributes(final_data.attributes);
|
||||||
|
return data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── POST /api/admin/assets ───────────────────────────────────────────────
|
// ─── POST /api/admin/assets ───────────────────────────────────────────────
|
||||||
// fields: { file (File), thumbnail? (File), uploadedBy, display_name,
|
|
||||||
// description, owner_type, owner_id, is_public, access_level,
|
|
||||||
// storage_provider, storage_bucket, storage_key }
|
|
||||||
const uploadAsset = useCallback(
|
const uploadAsset = useCallback(
|
||||||
({ file, thumbnail, ...rest }) =>
|
({ file, thumbnail, ...rest }) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -66,56 +106,44 @@ export function AssetsProvider({ children }) {
|
|||||||
if (v !== undefined && v !== null) form.append(k, v);
|
if (v !== undefined && v !== null) form.append(k, v);
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await api.post("/admin/assets", form, {
|
const res = await api.post("/admin/assets", form);
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
const asset = res.data?.data?.data ?? null;
|
||||||
});
|
|
||||||
const asset = res.data?.result?.data ?? null;
|
|
||||||
if (asset) setAssets((prev) => [asset, ...prev]);
|
|
||||||
return res.data;
|
|
||||||
}),
|
|
||||||
[request]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── PATCH /api/admin/assets/:assetId/thumbnail ───────────────────────────
|
|
||||||
const updateThumbnail = useCallback(
|
|
||||||
(assetId, thumbnailFile) =>
|
|
||||||
request(async () => {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append("thumbnail", thumbnailFile);
|
|
||||||
|
|
||||||
const res = await api.patch(`/admin/assets/${assetId}/thumbnail`, form, {
|
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
|
||||||
});
|
|
||||||
const asset = res.data?.result?.data ?? null;
|
|
||||||
if (asset) {
|
if (asset) {
|
||||||
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
|
setAssets((prev) => [asset, ...prev]);
|
||||||
setSelectedAsset((prev) => (prev?.asset_id === assetId ? asset : prev));
|
toast.success("Asset uploaded successfully.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── PUT /api/admin/assets/:assetId ───────────────────────────────────────
|
// ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
|
||||||
// Allowed: display_name, description, owner_type, owner_id, is_public,
|
|
||||||
// access_level, thumbnail_url, width, height, duration,
|
|
||||||
// frame_rate, bitrate, video_codec, audio_codec
|
|
||||||
const updateAsset = useCallback(
|
const updateAsset = useCallback(
|
||||||
(assetId, fields) =>
|
(assetId, fields, file = null) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.put(`/admin/assets/${assetId}`, fields);
|
const formData = new FormData();
|
||||||
const asset = res.data?.result?.data ?? null;
|
Object.entries(fields).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined && value !== null) formData.append(key, value);
|
||||||
|
});
|
||||||
|
if (file) formData.append("file", file);
|
||||||
|
|
||||||
|
const res = await api.patch(`/admin/assets/${assetId}`, formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const asset = res.data?.data?.data ?? null;
|
||||||
if (asset) {
|
if (asset) {
|
||||||
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
|
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
|
||||||
setSelectedAsset((prev) => (prev?.asset_id === assetId ? asset : prev));
|
setSelectedAsset(asset);
|
||||||
|
toast.success("Asset updated successfully.");
|
||||||
}
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── DELETE /api/admin/assets/:assetId ────────────────────────────────────
|
// ─── DELETE /api/admin/assets/:assetId ───────────────────────────────────
|
||||||
const deleteAsset = useCallback(
|
const archiveAsset = useCallback(
|
||||||
(assetId, { deletedBy } = {}) =>
|
(assetId, { deletedBy } = {}) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`/admin/assets/${assetId}`, {
|
const res = await api.delete(`/admin/assets/${assetId}`, {
|
||||||
@@ -123,51 +151,82 @@ 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));
|
||||||
|
toast.success("Asset archived.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── DELETE /api/admin/assets (bulk) ──────────────────────────────────────
|
// ─── DELETE /api/admin/assets/bulk ───────────────────────────────────────
|
||||||
const deleteAssets = useCallback(
|
const archiveAssets = useCallback(
|
||||||
(ids = [], { deletedBy } = {}) =>
|
({ ids }, { deletedBy } = {}) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete("/admin/assets", {
|
const res = await api.delete("/admin/assets/bulk", {
|
||||||
data: { ids, deletedBy },
|
data: { ids, deletedBy },
|
||||||
});
|
});
|
||||||
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
||||||
|
toast.success(`${ids.length} asset(s) archived.`);
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── PATCH /api/admin/assets/:assetId/restore ─────────────────────────────
|
// ─── PATCH /api/admin/assets/:assetId/restore ────────────────────────────
|
||||||
const restoreAsset = useCallback(
|
const restoreAsset = useCallback(
|
||||||
(assetId) =>
|
(assetId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.patch(`/admin/assets/${assetId}/restore`);
|
const res = await api.patch(`/admin/assets/${assetId}/restore`);
|
||||||
const asset = res.data?.result?.data ?? null;
|
const asset = res.data?.data?.data ?? null;
|
||||||
if (asset) setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
|
if (asset) {
|
||||||
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
||||||
|
toast.success("Asset restored.");
|
||||||
|
}
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── PATCH /api/admin/assets/bulk-restore ────────────────────────────────
|
||||||
|
const restoreAssets = useCallback(
|
||||||
|
({ ids }) =>
|
||||||
|
request(async () => {
|
||||||
|
const res = await api.patch("/admin/assets/bulk-restore", { ids });
|
||||||
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
||||||
|
toast.success(`${ids.length} asset(s) restored.`);
|
||||||
|
return res.data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/admin/assets/field-values ──────────────────────────────────────
|
||||||
|
const fetchAssetFieldValues = useCallback(
|
||||||
|
(field) =>
|
||||||
|
request(async () => {
|
||||||
|
const res = await api.get("/admin/assets/field-values", { params: { field } });
|
||||||
|
return res.data?.data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AssetsContext.Provider value={{
|
<AssetsContext.Provider value={{
|
||||||
assets,
|
assets,
|
||||||
|
attributes,
|
||||||
pagination,
|
pagination,
|
||||||
selectedAsset,
|
selectedAsset,
|
||||||
loading,
|
loading,
|
||||||
|
setPagination,
|
||||||
setSelectedAsset,
|
setSelectedAsset,
|
||||||
fetchAssets,
|
fetchAssets,
|
||||||
fetchAsset,
|
fetchAsset,
|
||||||
|
fetchArchivedAssets,
|
||||||
uploadAsset,
|
uploadAsset,
|
||||||
updateThumbnail,
|
|
||||||
updateAsset,
|
updateAsset,
|
||||||
deleteAsset,
|
archiveAsset,
|
||||||
deleteAssets,
|
archiveAssets,
|
||||||
restoreAsset,
|
restoreAsset,
|
||||||
|
restoreAssets,
|
||||||
|
fetchAssetFieldValues
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AssetsContext.Provider>
|
</AssetsContext.Provider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// ─── UserContext.jsx ───────────────────────────────────────────────────────────
|
|
||||||
import { createContext, useContext, useState, useCallback } from "react";
|
import { createContext, useContext, useState, useCallback } from "react";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const UserContext = createContext(null);
|
const UserContext = createContext(null);
|
||||||
|
|
||||||
@@ -10,7 +10,6 @@ export const useUsers = () => {
|
|||||||
return ctx;
|
return ctx;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Initial States ────────────────────────────────────────────────────────────
|
|
||||||
const PAGINATION_INIT = {
|
const PAGINATION_INIT = {
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
@@ -23,7 +22,6 @@ const PAGINATION_INIT = {
|
|||||||
const BASE = "/admin";
|
const BASE = "/admin";
|
||||||
|
|
||||||
export const UserProvider = ({ children }) => {
|
export const UserProvider = ({ children }) => {
|
||||||
// ─── State ─────────────────────────────────────────────────────────────────
|
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [sessions, setSessions] = useState([]);
|
const [sessions, setSessions] = useState([]);
|
||||||
@@ -32,7 +30,6 @@ export const UserProvider = ({ children }) => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
||||||
const request = useCallback(async (fn) => {
|
const request = useCallback(async (fn) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -41,6 +38,7 @@ export const UserProvider = ({ children }) => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
||||||
setError(message);
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -53,24 +51,41 @@ export const UserProvider = ({ children }) => {
|
|||||||
request(async () => {
|
request(async () => {
|
||||||
const { data } = await api.get(`${BASE}/users`, {
|
const { data } = await api.get(`${BASE}/users`, {
|
||||||
params: {
|
params: {
|
||||||
page,
|
page, limit,
|
||||||
limit,
|
|
||||||
filters: filters.length ? JSON.stringify(filters) : undefined,
|
filters: filters.length ? JSON.stringify(filters) : undefined,
|
||||||
sort: sort.length ? JSON.stringify(sort) : undefined,
|
sort: sort.length ? JSON.stringify(sort) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const final_data = data?.data;
|
||||||
const final_data = data?.data
|
|
||||||
setUsers(final_data?.data ?? []);
|
setUsers(final_data?.data ?? []);
|
||||||
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
||||||
setAttributes(final_data?.attributes ?? []);
|
setAttributes(final_data?.attributes ?? []);
|
||||||
|
|
||||||
return data?.data;
|
return data?.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── GET /api/admin/users/:id ──────────────────────────────────────────────
|
// ─── GET /api/admin/users/archived ────────────────────────────────────────
|
||||||
|
const fetchArchivedUsers = useCallback(
|
||||||
|
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||||
|
request(async () => {
|
||||||
|
const { data } = await api.get(`${BASE}/users/archived`, {
|
||||||
|
params: {
|
||||||
|
page, limit,
|
||||||
|
filters: filters.length ? JSON.stringify(filters) : undefined,
|
||||||
|
sort: sort.length ? JSON.stringify(sort) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const final_data = data?.data;
|
||||||
|
setUsers(final_data?.data ?? []);
|
||||||
|
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
||||||
|
setAttributes(final_data?.attributes ?? []);
|
||||||
|
return data?.data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/admin/users/:id ─────────────────────────────────────────────
|
||||||
const fetchUser = useCallback(
|
const fetchUser = useCallback(
|
||||||
(userId) =>
|
(userId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -81,88 +96,59 @@ export const UserProvider = ({ children }) => {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── POST /api/admin/users/staff ──────────────────────────────────────────────
|
// ─── POST /api/admin/users/staff ──────────────────────────────────────────
|
||||||
const addStaffUser = useCallback(
|
const addStaffUser = useCallback(
|
||||||
(payload) =>
|
(payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/users/staff`, payload);
|
const res = await api.post(`${BASE}/users/staff`, payload);
|
||||||
|
toast.success("Staff user added successfully.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── PUT /api/admin/users/:id ──────────────────────────────────────────────
|
// ─── PUT /api/admin/users/:id ─────────────────────────────────────────────
|
||||||
const updateUser = useCallback(
|
const updateUser = useCallback(
|
||||||
(userId, payload) =>
|
(userId, payload) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.put(`${BASE}/users/${userId}`, payload);
|
const res = await api.put(`${BASE}/users/${userId}`, payload);
|
||||||
|
|
||||||
// Sync local list if user exists in it
|
|
||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) => (u.user_id === userId ? { ...u, ...res.data?.data } : u))
|
prev.map((u) => (u.user_id === userId ? { ...u, ...res.data?.data } : u))
|
||||||
);
|
);
|
||||||
|
toast.success("User updated successfully.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── GET /api/admin/users/archived ────────────────────────────────────────────
|
// ─── DELETE /api/admin/users/:id ──────────────────────────────────────────
|
||||||
const fetchArchivedUsers = useCallback(
|
|
||||||
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
|
||||||
request(async () => {
|
|
||||||
const { data } = await api.get(`${BASE}/users/archived`, {
|
|
||||||
params: {
|
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
filters: filters.length ? JSON.stringify(filters) : undefined,
|
|
||||||
sort: sort.length ? JSON.stringify(sort) : undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const final_data = data?.data
|
|
||||||
setUsers(final_data?.data ?? []);
|
|
||||||
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
|
||||||
setAttributes(final_data?.attributes ?? []);
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
}),
|
|
||||||
[request]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── DELETE /api/admin/users/:id (soft delete) ────────────────────────────
|
|
||||||
const deactivateUser = useCallback(
|
const deactivateUser = useCallback(
|
||||||
(userId) =>
|
(userId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`${BASE}/users/${userId}`);
|
const res = await api.delete(`${BASE}/users/${userId}`);
|
||||||
|
|
||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) => (u.user_id === userId ? { ...u, is_active: false } : u))
|
prev.map((u) => (u.user_id === userId ? { ...u, is_active: false } : u))
|
||||||
);
|
);
|
||||||
|
toast.success("User deactivated.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── DELETE /api/admin/users/bulk ─────────────────────────────────────────────
|
// ─── DELETE /api/admin/users/bulk ─────────────────────────────────────────
|
||||||
const deactivateUsers = useCallback(
|
const deactivateUsers = useCallback(
|
||||||
({ ids }) =>
|
({ ids }) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`${BASE}/users/bulk`, { data: { ids } });
|
const res = await api.delete(`${BASE}/users/bulk`, { data: { ids } });
|
||||||
|
const { deactivated_ids } = res.data?.data ?? {};
|
||||||
const { deactivated_ids } = res.data?.data ?? {}; // ← unwrap nested data
|
|
||||||
|
|
||||||
if (deactivated_ids?.length) {
|
if (deactivated_ids?.length) {
|
||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) =>
|
prev.map((u) =>
|
||||||
deactivated_ids.includes(u.user_id)
|
deactivated_ids.includes(u.user_id) ? { ...u, is_active: false } : u
|
||||||
? { ...u, is_active: false }
|
|
||||||
: u
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
toast.success(`${deactivated_ids.length} user(s) deactivated.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -173,33 +159,29 @@ export const UserProvider = ({ children }) => {
|
|||||||
(userId) =>
|
(userId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/users/${userId}/restore`);
|
const res = await api.post(`${BASE}/users/${userId}/restore`);
|
||||||
|
|
||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) => (u.user_id === userId ? { ...u, is_active: true } : u))
|
prev.map((u) => (u.user_id === userId ? { ...u, is_active: true } : u))
|
||||||
);
|
);
|
||||||
|
toast.success("User restored.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/admin/users/bulk/restore ───────────────────────────────────
|
||||||
const restoreUsers = useCallback(
|
const restoreUsers = useCallback(
|
||||||
({ ids }) =>
|
({ ids }) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/users/bulk/restore`, { ids });
|
const res = await api.post(`${BASE}/users/bulk/restore`, { ids });
|
||||||
|
const { restored_ids } = res.data?.data ?? {};
|
||||||
const { restored_ids } = res.data?.data ?? {}; // ← same unwrap
|
|
||||||
|
|
||||||
if (restored_ids?.length) {
|
if (restored_ids?.length) {
|
||||||
setUsers((prev) =>
|
setUsers((prev) =>
|
||||||
prev.map((u) =>
|
prev.map((u) =>
|
||||||
restored_ids.includes(u.user_id)
|
restored_ids.includes(u.user_id) ? { ...u, is_active: true } : u
|
||||||
? { ...u, is_active: true }
|
|
||||||
: u
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
toast.success(`${restored_ids.length} user(s) restored.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
@@ -221,17 +203,16 @@ export const UserProvider = ({ children }) => {
|
|||||||
(userId, sessionId) =>
|
(userId, sessionId) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`${BASE}/users/${userId}/sessions/${sessionId}`);
|
const res = await api.delete(`${BASE}/users/${userId}/sessions/${sessionId}`);
|
||||||
|
|
||||||
setSessions((prev) =>
|
setSessions((prev) =>
|
||||||
prev.map((s) => (s.session_id === sessionId ? { ...s, is_active: false } : s))
|
prev.map((s) => (s.session_id === sessionId ? { ...s, is_active: false } : s))
|
||||||
);
|
);
|
||||||
|
toast.success("Session terminated.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── GET /api/admin/users/field-values?field=acc_type ─────────────────────────
|
// ─── GET /api/admin/users/field-values ────────────────────────────────────
|
||||||
const fetchUserFieldValues = useCallback(
|
const fetchUserFieldValues = useCallback(
|
||||||
(field) =>
|
(field) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -241,37 +222,17 @@ export const UserProvider = ({ children }) => {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Provider ──────────────────────────────────────────────────────────────
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider
|
<UserContext.Provider value={{
|
||||||
value={{
|
users, user, sessions, pagination, attributes, loading, error,
|
||||||
// state
|
|
||||||
users,
|
|
||||||
user,
|
|
||||||
sessions,
|
|
||||||
pagination,
|
|
||||||
attributes,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
|
|
||||||
// setters
|
|
||||||
setPagination,
|
setPagination,
|
||||||
|
fetchUsers, fetchArchivedUsers, fetchUser,
|
||||||
// actions
|
addStaffUser, updateUser,
|
||||||
|
deactivateUser, deactivateUsers,
|
||||||
|
restoreUser, restoreUsers,
|
||||||
|
fetchUserSessions, terminateSession,
|
||||||
fetchUserFieldValues,
|
fetchUserFieldValues,
|
||||||
fetchUsers,
|
}}>
|
||||||
fetchArchivedUsers,
|
|
||||||
fetchUser,
|
|
||||||
addStaffUser,
|
|
||||||
updateUser,
|
|
||||||
deactivateUser,
|
|
||||||
deactivateUsers,
|
|
||||||
restoreUser,
|
|
||||||
restoreUsers,
|
|
||||||
fetchUserSessions,
|
|
||||||
terminateSession,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
/***********************************************************************************************************************************************************************
|
|
||||||
* File Name: AdminUserGroupContext.jsx
|
|
||||||
* Type of Program: Context
|
|
||||||
* Description: Admin-level user group management context.
|
|
||||||
* Covers: list groups, get single group + members, create, update,
|
|
||||||
* deactivate, restore, add/remove members.
|
|
||||||
*
|
|
||||||
* Author: rgrgogu
|
|
||||||
***********************************************************************************************************************************************************************/
|
|
||||||
import { createContext, useCallback, useContext, useState } from "react";
|
import { createContext, useCallback, useContext, useState } from "react";
|
||||||
import api from "@/utils/api.util";
|
import api from "@/utils/api.util";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const BASE = "/admin";
|
const BASE = "/admin";
|
||||||
|
|
||||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
|
||||||
const UserGroupContext = createContext(null);
|
const UserGroupContext = createContext(null);
|
||||||
|
|
||||||
export function useUserGroups() {
|
export function useUserGroups() {
|
||||||
@@ -22,18 +11,16 @@ export function useUserGroups() {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
|
||||||
export function UserGroupProvider({ children }) {
|
export function UserGroupProvider({ children }) {
|
||||||
const [groups, setGroups] = useState([]);
|
const [groups, setGroups] = useState([]);
|
||||||
const [group, setGroup] = useState(null); // single group + its members
|
const [group, setGroup] = useState(null);
|
||||||
const [members, setMembers] = useState([]); // members inside current group
|
const [members, setMembers] = useState([]);
|
||||||
const [usersIn, setUsersIn] = useState([]); // users already in group (for remove)
|
const [usersIn, setUsersIn] = useState([]);
|
||||||
const [usersNotIn, setUsersNotIn] = useState([]); // users not in group (for add)
|
const [usersNotIn, setUsersNotIn] = useState([]);
|
||||||
const [attributes, setAttributes] = useState([]);
|
const [attributes, setAttributes] = useState([]);
|
||||||
const [pagination, setPagination] = useState({ page: 1, limit: 10, total: 0, totalPages: 1 });
|
const [pagination, setPagination] = useState({ page: 1, limit: 10, total: 0, totalPages: 1 });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// ─── Generic request wrapper ────────────────────────────────────────────────
|
|
||||||
const request = useCallback(async (fn) => {
|
const request = useCallback(async (fn) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -54,40 +41,49 @@ export function UserGroupProvider({ children }) {
|
|||||||
const res = await api.get(`${BASE}/groups`, {
|
const res = await api.get(`${BASE}/groups`, {
|
||||||
params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) },
|
params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {};
|
const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {};
|
||||||
|
|
||||||
setGroups(data ?? []);
|
setGroups(data ?? []);
|
||||||
setAttributes(attrs ?? []);
|
setAttributes(attrs ?? []);
|
||||||
setPagination(pg ?? { page, limit, total: 0, totalPages: 1 });
|
setPagination(pg ?? { page, limit, total: 0, totalPages: 1 });
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── GET /api/admin/groups/:gid ────────────────────────────────────────────
|
// ─── GET /api/admin/groups/archived ───────────────────────────────────────
|
||||||
|
const fetchArchivedGroups = useCallback(
|
||||||
|
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||||
|
request(async () => {
|
||||||
|
const res = await api.get(`${BASE}/groups/archived`, {
|
||||||
|
params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) },
|
||||||
|
});
|
||||||
|
const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {};
|
||||||
|
setGroups(data ?? []);
|
||||||
|
setAttributes(attrs ?? []);
|
||||||
|
setPagination(pg ?? { page, limit, total: 0, totalPages: 1 });
|
||||||
|
return res.data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/admin/groups/:gid ───────────────────────────────────────────
|
||||||
const fetchGroup = useCallback(
|
const fetchGroup = useCallback(
|
||||||
(gid, paginationParams = {}) =>
|
(gid, paginationParams = {}) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const { page = 1, limit = 10, filters = [], sort = [] } = paginationParams;
|
const { page = 1, limit = 10, filters = [], sort = [] } = paginationParams;
|
||||||
|
|
||||||
const res = await api.get(`${BASE}/groups/${gid}`, {
|
const res = await api.get(`${BASE}/groups/${gid}`, {
|
||||||
params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) },
|
params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { group: g, members: m } = res.data?.data ?? {};
|
const { group: g, members: m } = res.data?.data ?? {};
|
||||||
|
|
||||||
setGroup(g ?? null);
|
setGroup(g ?? null);
|
||||||
setMembers(m?.data ?? []);
|
setMembers(m?.data ?? []);
|
||||||
setPagination(m?.pagination ?? { page, limit, total: 0, totalPages: 1 });
|
setPagination(m?.pagination ?? { page, limit, total: 0, totalPages: 1 });
|
||||||
|
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── GET /api/admin/groups/:gid/users ──────────────────────────────────────
|
// ─── GET /api/admin/groups/:gid/users ─────────────────────────────────────
|
||||||
const fetchUsersInGroup = useCallback(
|
const fetchUsersInGroup = useCallback(
|
||||||
(gid) =>
|
(gid) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -98,7 +94,7 @@ export function UserGroupProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── GET /api/admin/groups/:gid/users/add ──────────────────────────────────
|
// ─── GET /api/admin/groups/:gid/users/add ─────────────────────────────────
|
||||||
const fetchUsersNotInGroup = useCallback(
|
const fetchUsersNotInGroup = useCallback(
|
||||||
(gid) =>
|
(gid) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
@@ -109,80 +105,115 @@ export function UserGroupProvider({ children }) {
|
|||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── GET /api/admin/groups/field-values ───────────────────────────────────
|
||||||
|
const fetchGroupFieldValues = useCallback(
|
||||||
|
(field) =>
|
||||||
|
request(async () => {
|
||||||
|
const res = await api.get(`${BASE}/groups/field-values`, { params: { field } });
|
||||||
|
return res.data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── POST /api/admin/groups ────────────────────────────────────────────────
|
// ─── POST /api/admin/groups ────────────────────────────────────────────────
|
||||||
const createGroup = useCallback(
|
const createGroup = useCallback(
|
||||||
({ name, description }) =>
|
({ name, description }) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/groups`, { name, description });
|
const res = await api.post(`${BASE}/groups`, { name, description });
|
||||||
|
|
||||||
setGroups((prev) => [res.data?.data, ...prev]);
|
setGroups((prev) => [res.data?.data, ...prev]);
|
||||||
|
|
||||||
toast.success("Group created successfully.");
|
toast.success("Group created successfully.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── PUT /api/admin/groups/:gid ────────────────────────────────────────────
|
// ─── PUT /api/admin/groups/:gid ───────────────────────────────────────────
|
||||||
const updateGroup = useCallback(
|
const updateGroup = useCallback(
|
||||||
(gid, { name, description }) =>
|
(gid, { name, description }) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.put(`${BASE}/groups/${gid}`, { name, description });
|
const res = await api.put(`${BASE}/groups/${gid}`, { name, description });
|
||||||
|
|
||||||
setGroups((prev) =>
|
setGroups((prev) =>
|
||||||
prev.map((g) => (g.group_id === gid ? { ...g, ...res.data?.data } : g))
|
prev.map((g) => (g.group_id === gid ? { ...g, ...res.data?.data } : g))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update single group view if open
|
|
||||||
setGroup((prev) => (prev?.group_id === gid ? { ...prev, ...res.data?.data } : prev));
|
setGroup((prev) => (prev?.group_id === gid ? { ...prev, ...res.data?.data } : prev));
|
||||||
|
|
||||||
toast.success("Group updated successfully.");
|
toast.success("Group updated successfully.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── PATCH /api/admin/groups/:gid/deactivate ───────────────────────────────
|
// ─── PATCH /api/admin/groups/:gid/deactivate ──────────────────────────────
|
||||||
const deactivateGroup = useCallback(
|
const deactivateGroup = useCallback(
|
||||||
(gid) =>
|
(gid) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.patch(`${BASE}/groups/${gid}/deactivate`);
|
const res = await api.patch(`${BASE}/groups/${gid}/deactivate`);
|
||||||
|
|
||||||
setGroups((prev) =>
|
setGroups((prev) =>
|
||||||
prev.map((g) => (g.group_id === gid ? { ...g, is_active: false } : g))
|
prev.map((g) => (g.group_id === gid ? { ...g, is_active: false } : g))
|
||||||
);
|
);
|
||||||
|
|
||||||
toast.success("Group deactivated.");
|
toast.success("Group deactivated.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── DELETE /api/admin/groups/bulk ────────────────────────────────────────
|
||||||
|
const deactivateGroups = useCallback(
|
||||||
|
({ ids }) =>
|
||||||
|
request(async () => {
|
||||||
|
const res = await api.delete(`${BASE}/groups/bulk`, { data: { ids } });
|
||||||
|
const { deactivated_ids } = res.data?.data ?? {};
|
||||||
|
if (deactivated_ids?.length) {
|
||||||
|
setGroups((prev) =>
|
||||||
|
prev.map((g) =>
|
||||||
|
deactivated_ids.includes(g.group_id) ? { ...g, is_active: false } : g
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast.success(`${deactivated_ids.length} group(s) deactivated.`);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── PATCH /api/admin/groups/:gid/restore ─────────────────────────────────
|
// ─── PATCH /api/admin/groups/:gid/restore ─────────────────────────────────
|
||||||
const restoreGroup = useCallback(
|
const restoreGroup = useCallback(
|
||||||
(gid) =>
|
(gid) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.patch(`${BASE}/groups/${gid}/restore`);
|
const res = await api.patch(`${BASE}/groups/${gid}/restore`);
|
||||||
|
|
||||||
setGroups((prev) =>
|
setGroups((prev) =>
|
||||||
prev.map((g) => (g.group_id === gid ? { ...g, is_active: true } : g))
|
prev.map((g) => (g.group_id === gid ? { ...g, is_active: true } : g))
|
||||||
);
|
);
|
||||||
|
|
||||||
toast.success("Group restored.");
|
toast.success("Group restored.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── POST /api/admin/groups/bulk/restore ──────────────────────────────────
|
||||||
|
const restoreGroups = useCallback(
|
||||||
|
({ ids }) =>
|
||||||
|
request(async () => {
|
||||||
|
const res = await api.post(`${BASE}/groups/bulk/restore`, { ids });
|
||||||
|
const { restored_ids } = res.data?.data ?? {};
|
||||||
|
if (restored_ids?.length) {
|
||||||
|
setGroups((prev) =>
|
||||||
|
prev.map((g) =>
|
||||||
|
restored_ids.includes(g.group_id) ? { ...g, is_active: true } : g
|
||||||
|
)
|
||||||
|
);
|
||||||
|
toast.success(`${restored_ids.length} group(s) restored.`);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}),
|
||||||
|
[request]
|
||||||
|
);
|
||||||
|
|
||||||
// ─── POST /api/admin/groups/:gid/users ────────────────────────────────────
|
// ─── POST /api/admin/groups/:gid/users ────────────────────────────────────
|
||||||
const addUsersToGroup = useCallback(
|
const addUsersToGroup = useCallback(
|
||||||
(gid, user_ids) =>
|
(gid, user_ids) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.post(`${BASE}/groups/${gid}/users`, { user_ids });
|
const res = await api.post(`${BASE}/groups/${gid}/users`, { user_ids });
|
||||||
|
|
||||||
// Remove added users from usersNotIn list
|
|
||||||
setUsersNotIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
setUsersNotIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
||||||
|
|
||||||
toast.success("Users added to group.");
|
toast.success("Users added to group.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
@@ -194,116 +225,25 @@ export function UserGroupProvider({ children }) {
|
|||||||
(gid, user_ids) =>
|
(gid, user_ids) =>
|
||||||
request(async () => {
|
request(async () => {
|
||||||
const res = await api.delete(`${BASE}/groups/${gid}/users`, { data: { user_ids } });
|
const res = await api.delete(`${BASE}/groups/${gid}/users`, { data: { user_ids } });
|
||||||
|
|
||||||
// Remove from members list
|
|
||||||
setMembers((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
setMembers((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
||||||
setUsersIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
setUsersIn((prev) => prev.filter((u) => !user_ids.includes(u.user_id)));
|
||||||
|
|
||||||
toast.success("Users removed from group.");
|
toast.success("Users removed from group.");
|
||||||
return res.data;
|
return res.data;
|
||||||
}),
|
}),
|
||||||
[request]
|
[request]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── DELETE /api/admin/groups/bulk ────────────────────────────────────────────
|
|
||||||
const deactivateGroups = useCallback(
|
|
||||||
({ ids }) =>
|
|
||||||
request(async () => {
|
|
||||||
const res = await api.delete(`${BASE}/groups/bulk`, { data: { ids } });
|
|
||||||
const { deactivated_ids } = res.data?.data ?? {};
|
|
||||||
|
|
||||||
if (deactivated_ids?.length) {
|
|
||||||
setGroups((prev) =>
|
|
||||||
prev.map((g) =>
|
|
||||||
deactivated_ids.includes(g.group_id) ? { ...g, is_active: false } : g
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
}),
|
|
||||||
[request]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── POST /api/admin/groups/bulk/restore ──────────────────────────────────────
|
|
||||||
const restoreGroups = useCallback(
|
|
||||||
({ ids }) =>
|
|
||||||
request(async () => {
|
|
||||||
const res = await api.post(`${BASE}/groups/bulk/restore`, { ids });
|
|
||||||
const { restored_ids } = res.data?.data ?? {};
|
|
||||||
|
|
||||||
if (restored_ids?.length) {
|
|
||||||
setGroups((prev) =>
|
|
||||||
prev.map((g) =>
|
|
||||||
restored_ids.includes(g.group_id) ? { ...g, is_active: true } : g
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
}),
|
|
||||||
[request]
|
|
||||||
);
|
|
||||||
|
|
||||||
// ─── GET /api/admin/groups/field-values ───────────────────────────────────────
|
|
||||||
const fetchGroupFieldValues = useCallback(
|
|
||||||
(field) =>
|
|
||||||
request(async () => {
|
|
||||||
const res = await api.get(`${BASE}/groups/field-values`, { params: { field } });
|
|
||||||
return res.data;
|
|
||||||
}),
|
|
||||||
[request]
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchArchivedGroups = useCallback(
|
|
||||||
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
|
||||||
request(async () => {
|
|
||||||
const res = await api.get(`${BASE}/groups/archived`, {
|
|
||||||
params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {};
|
|
||||||
|
|
||||||
setGroups(data ?? []);
|
|
||||||
setAttributes(attrs ?? []);
|
|
||||||
setPagination(pg ?? { page, limit, total: 0, totalPages: 1 });
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
}),
|
|
||||||
[request]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserGroupContext.Provider
|
<UserGroupContext.Provider value={{
|
||||||
value={{
|
groups, group, members, usersIn, usersNotIn, attributes, pagination, loading,
|
||||||
// state
|
|
||||||
groups,
|
|
||||||
group,
|
|
||||||
members,
|
|
||||||
usersIn,
|
|
||||||
usersNotIn,
|
|
||||||
attributes,
|
|
||||||
pagination,
|
|
||||||
setPagination,
|
setPagination,
|
||||||
loading,
|
fetchGroups, fetchArchivedGroups, fetchGroup,
|
||||||
|
fetchUsersInGroup, fetchUsersNotInGroup, fetchGroupFieldValues,
|
||||||
// actions
|
createGroup, updateGroup,
|
||||||
fetchGroups,
|
deactivateGroup, deactivateGroups,
|
||||||
fetchGroup,
|
restoreGroup, restoreGroups,
|
||||||
fetchUsersInGroup,
|
addUsersToGroup, removeUsersFromGroup,
|
||||||
fetchUsersNotInGroup,
|
}}>
|
||||||
fetchGroupFieldValues,
|
|
||||||
fetchArchivedGroups,
|
|
||||||
createGroup,
|
|
||||||
updateGroup,
|
|
||||||
deactivateGroup,
|
|
||||||
deactivateGroups,
|
|
||||||
restoreGroup,
|
|
||||||
restoreGroups,
|
|
||||||
addUsersToGroup,
|
|
||||||
removeUsersFromGroup,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</UserGroupContext.Provider>
|
</UserGroupContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
// ─── AdminProvider.jsx ─────────────────────────────────────────────────────────
|
// ─── AdminProvider.jsx ─────────────────────────────────────────────────────────
|
||||||
|
import { AssetsProvider } from "../AdminAssetsContext";
|
||||||
import { AdminDashboardProvider } from "../AdminDashboardContext"
|
import { AdminDashboardProvider } from "../AdminDashboardContext"
|
||||||
import { UserProvider } from "../AdminUserContext";
|
import { UserProvider } from "../AdminUserContext";
|
||||||
import { UserGroupProvider } from "../AdminUserGroupContext";
|
import { UserGroupProvider } from "../AdminUserGroupContext";
|
||||||
@@ -6,11 +7,13 @@ import { UserGroupProvider } from "../AdminUserGroupContext";
|
|||||||
export const AdminProvider = ({ children }) => {
|
export const AdminProvider = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<AdminDashboardProvider>
|
<AdminDashboardProvider>
|
||||||
|
<AssetsProvider>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<UserGroupProvider>
|
<UserGroupProvider>
|
||||||
{children}
|
{children}
|
||||||
</UserGroupProvider>
|
</UserGroupProvider>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
</AssetsProvider>
|
||||||
</AdminDashboardProvider>
|
</AdminDashboardProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+20
-23
@@ -1,35 +1,32 @@
|
|||||||
import { Users, GitFork } from "lucide-react";
|
// data/adminTiles.data.js
|
||||||
|
|
||||||
export const USER_MANAGEMENT = [
|
import { Users, GitFork, FolderOpen } from "lucide-react";
|
||||||
|
|
||||||
|
export const ADMIN_SECTIONS = [
|
||||||
{
|
{
|
||||||
|
id: "section-users",
|
||||||
|
tab: "User Management",
|
||||||
title: "User Management",
|
title: "User Management",
|
||||||
description: "Manage people across different systems",
|
description: "Manage people across different systems",
|
||||||
tiles: [
|
tiles: [
|
||||||
{ key: "users", label: "Users", icon: Users, link: "all" },
|
{ key: "users", label: "Users", icon: Users, link: "/admin/users" },
|
||||||
{ key: "user-groups", label: "User Groups", icon: GitFork, link: "groups" },
|
{ key: "user-groups", label: "User Groups", icon: GitFork, link: "/admin/groups" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "section-assets",
|
||||||
|
tab: "Asset Management",
|
||||||
title: "Asset Management",
|
title: "Asset Management",
|
||||||
description: "Manage people across different systems",
|
description: "Upload images, documents and videos",
|
||||||
tiles: [
|
tiles: [
|
||||||
{ key: "users", label: "Assets", icon: Users, link: "all" },
|
{ key: "assets", label: "Assets", icon: FolderOpen, link: "/admin/assets" },
|
||||||
{ key: "user-groups", label: "User Groups", icon: GitFork, link: "groups" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
{
|
||||||
|
id: "section-site",
|
||||||
export const CONTENT_MANAGEMENT = [
|
tab: "Site Content",
|
||||||
// {
|
title: "Site Content",
|
||||||
// title: "User Management",
|
description: "Manage public-facing content",
|
||||||
// description: "Manage people with their account permissions here.",
|
tiles: [],
|
||||||
// tiles: [
|
},
|
||||||
// { key: "users", label: "Users", icon: Users, link: "all" },
|
];
|
||||||
// { key: "user-groups", label: "User Groups", icon: GitFork, link: "groups" },
|
|
||||||
// ],
|
|
||||||
// },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const SITE_CONTENT = [
|
|
||||||
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
|
||||||
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
|
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
||||||
|
|
||||||
|
import { buildDataColumns, columnPinning } from "../../config/assets/archive/columns.config";
|
||||||
|
import { buildToolbarActions } from "../../config/assets/archive/toolbar.config";
|
||||||
|
import { buildRowActions } from "../../config/assets/archive/rowActions.config";
|
||||||
|
import { buildSelectionActions } from "../../config/assets/archive/selection.config";
|
||||||
|
|
||||||
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
|
||||||
|
export default function ArchivedAssetsTable() {
|
||||||
|
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||||
|
const [restoreIds, setRestoreIds] = useState(null);
|
||||||
|
|
||||||
|
const tableRefsRef = useRef({
|
||||||
|
getFilters: () => [],
|
||||||
|
getSort: () => [],
|
||||||
|
resetSelection: () => {},
|
||||||
|
setFilters: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
assets, attributes, pagination, setPagination, loading,
|
||||||
|
fetchArchivedAssets, restoreAsset, restoreAssets, fetchAssetFieldValues
|
||||||
|
} = useAssets();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchArchivedAssets({ page: 1, limit: pagination?.limit ?? 10 });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRefsReady = (refs) => {
|
||||||
|
tableRefsRef.current = refs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportConfig = {
|
||||||
|
allData: assets,
|
||||||
|
attributes,
|
||||||
|
filename: `${getTimestamp()}_ArchivedAssets`,
|
||||||
|
sheetName: "Archived Assets",
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowActions = buildRowActions({
|
||||||
|
onRestore: (row) => setRestoreTarget(row),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolbarActions = buildToolbarActions({
|
||||||
|
fetchAssets: fetchArchivedAssets,
|
||||||
|
pagination,
|
||||||
|
exportConfig,
|
||||||
|
navigate,
|
||||||
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
|
getSort: () => tableRefsRef.current.getSort(),
|
||||||
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionActions = buildSelectionActions({
|
||||||
|
exportConfig,
|
||||||
|
onSingleRestore: (row) => setRestoreTarget(row),
|
||||||
|
onBulkRestore: (ids) => setRestoreIds(ids),
|
||||||
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => buildDataColumns(attributes, rowActions),
|
||||||
|
[attributes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRestoreSuccess = () => {
|
||||||
|
setRestoreTarget(null);
|
||||||
|
setRestoreIds(null);
|
||||||
|
tableRefsRef.current.resetSelection?.();
|
||||||
|
fetchArchivedAssets({ page: 1, limit: pagination?.limit ?? 10 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
title="Archived Assets"
|
||||||
|
data={assets}
|
||||||
|
columns={columns}
|
||||||
|
attributes={attributes}
|
||||||
|
pagination={pagination}
|
||||||
|
setPagination={setPagination}
|
||||||
|
loading={loading}
|
||||||
|
onFetch={fetchArchivedAssets}
|
||||||
|
onFetchFilterData={fetchAssetFieldValues}
|
||||||
|
onRefsReady={handleRefsReady}
|
||||||
|
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
column={column}
|
||||||
|
attr={attr}
|
||||||
|
data={data}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
columnPinning={columnPinning}
|
||||||
|
toolbarActions={toolbarActions}
|
||||||
|
selectionActions={selectionActions}
|
||||||
|
recordLabel="archived asset"
|
||||||
|
emptyMessage="No archived assets found."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Single restore ── */}
|
||||||
|
<RestoreDialog
|
||||||
|
open={!!restoreTarget}
|
||||||
|
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||||
|
entity={restoreTarget}
|
||||||
|
entityLabel="Asset"
|
||||||
|
getName={(a) => a?.display_name ?? a?.original_name}
|
||||||
|
onRestore={(a) => restoreAsset(a?.asset_id)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleRestoreSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Bulk restore ── */}
|
||||||
|
<RestoreDialog
|
||||||
|
open={!!restoreIds}
|
||||||
|
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||||
|
ids={restoreIds ?? []}
|
||||||
|
entityLabel="Asset"
|
||||||
|
onRestore={(ids) => restoreAssets(ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleRestoreSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// modules/admin/components/assets/AssetsTable.jsx
|
||||||
|
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
|
||||||
|
import DataTable from "@/components/generic/Table/DataTable";
|
||||||
|
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||||
|
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||||
|
|
||||||
|
import { buildDataColumns, columnPinning } from "../../config/assets/columns.config";
|
||||||
|
import { buildToolbarActions } from "../../config/assets/toolbar.config";
|
||||||
|
import { buildSelectionActions } from "../../config/assets/selection.config";
|
||||||
|
import { buildRowActions } from "../../config/assets/rowActions.config";
|
||||||
|
|
||||||
|
import { getTimestamp } from "@/utils/timestamp.util";
|
||||||
|
|
||||||
|
export default function AssetsTable() {
|
||||||
|
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||||
|
const [archiveIds, setArchiveIds] = useState(null);
|
||||||
|
|
||||||
|
const tableRefsRef = useRef({
|
||||||
|
getFilters: () => [],
|
||||||
|
getSort: () => [],
|
||||||
|
resetSelection: () => {},
|
||||||
|
setFilters: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
assets, attributes, pagination, setPagination, loading,
|
||||||
|
fetchAssets, archiveAsset, archiveAssets, fetchAssetFieldValues
|
||||||
|
} = useAssets();
|
||||||
|
|
||||||
|
const handleRefsReady = (refs) => {
|
||||||
|
tableRefsRef.current = refs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportConfig = {
|
||||||
|
allData: assets,
|
||||||
|
attributes,
|
||||||
|
filename: `${getTimestamp()}_Assets`,
|
||||||
|
sheetName: "Assets",
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveViewPath = (row) => {
|
||||||
|
switch (row.file_type) {
|
||||||
|
case "video": return `view/video/${row.asset_id}`;
|
||||||
|
case "image": return `view/image/${row.asset_id}`;
|
||||||
|
case "document": return `view/document/${row.asset_id}`;
|
||||||
|
default: return `view/image/${row.asset_id}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowActions = buildRowActions({
|
||||||
|
onView: (row) => navigate(resolveViewPath(row)),
|
||||||
|
onEdit: (row) => navigate(`edit/${row.asset_id}`),
|
||||||
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toolbarActions = buildToolbarActions({
|
||||||
|
fetchAssets, pagination, exportConfig, navigate,
|
||||||
|
getFilters: () => tableRefsRef.current.getFilters(),
|
||||||
|
getSort: () => tableRefsRef.current.getSort(),
|
||||||
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionActions = buildSelectionActions({
|
||||||
|
exportConfig,
|
||||||
|
onArchive: (row) => setArchiveTarget(row),
|
||||||
|
onArchiveMany: (ids) => setArchiveIds(ids),
|
||||||
|
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = useMemo(
|
||||||
|
() => buildDataColumns(attributes, rowActions),
|
||||||
|
[attributes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleArchiveSuccess = () => {
|
||||||
|
setArchiveTarget(null);
|
||||||
|
setArchiveIds(null);
|
||||||
|
tableRefsRef.current.resetSelection?.();
|
||||||
|
fetchAssets({ page: 1, limit: pagination?.limit ?? 10 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
title="Assets"
|
||||||
|
data={assets}
|
||||||
|
columns={columns}
|
||||||
|
attributes={attributes}
|
||||||
|
pagination={pagination}
|
||||||
|
setPagination={setPagination}
|
||||||
|
loading={loading}
|
||||||
|
onFetch={fetchAssets}
|
||||||
|
onFetchFilterData={fetchAssetFieldValues}
|
||||||
|
onRefsReady={handleRefsReady}
|
||||||
|
renderFilterSheet={({ open, onOpenChange, column, attr, data, loading }) => (
|
||||||
|
<FilterSheet
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
column={column}
|
||||||
|
attr={attr}
|
||||||
|
data={data}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
columnPinning={columnPinning}
|
||||||
|
toolbarActions={toolbarActions}
|
||||||
|
selectionActions={selectionActions}
|
||||||
|
recordLabel="asset"
|
||||||
|
emptyMessage="No assets match the current filters."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Single archive ── */}
|
||||||
|
<ArchiveDialog
|
||||||
|
open={!!archiveTarget}
|
||||||
|
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||||
|
entity={archiveTarget}
|
||||||
|
entityLabel="Asset"
|
||||||
|
getName={(a) => a?.display_name ?? a?.original_name}
|
||||||
|
onArchive={(a) => archiveAsset(a?.asset_id)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleArchiveSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Bulk archive ── */}
|
||||||
|
<ArchiveDialog
|
||||||
|
open={!!archiveIds}
|
||||||
|
onOpenChange={(v) => !v && setArchiveIds(null)}
|
||||||
|
ids={archiveIds ?? []}
|
||||||
|
entityLabel="Asset"
|
||||||
|
onArchive={(ids) => archiveAssets(ids)}
|
||||||
|
loading={loading}
|
||||||
|
onSuccess={handleArchiveSuccess}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// config/columns.config.jsx
|
||||||
|
// Column definitions and pinning config for the Users table.
|
||||||
|
|
||||||
|
import { buildColumns } from "@/utils/table.util";
|
||||||
|
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||||
|
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
|
||||||
|
export const columnPinning = {
|
||||||
|
right: ["actions"],
|
||||||
|
left: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||||
|
const cellOverrides = {
|
||||||
|
// memberCount: (info) => {
|
||||||
|
// const count = parseInt(info.getValue() ?? 0, 10);
|
||||||
|
// return (
|
||||||
|
// <div className="flex items-center gap-1.5">
|
||||||
|
// <Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
// <Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||||
|
// {count} {count === 1 ? "member" : "members"}
|
||||||
|
// </Badge>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the full column array for the Users table.
|
||||||
|
*
|
||||||
|
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||||
|
* @param {Array} rowActions Row-level kebab action definitions
|
||||||
|
* @returns {Array} TanStack column definitions
|
||||||
|
*/
|
||||||
|
export function buildDataColumns(attributes, rowActions) {
|
||||||
|
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||||
|
|
||||||
|
return [
|
||||||
|
buildSelectionColumn(),
|
||||||
|
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||||
|
buildRowActionsColumn(rowActions, { dropdownLabel: "Asset Actions" }),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// modules/admin/config/assets/rowActions.config.jsx
|
||||||
|
import { RotateCcw } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} deps
|
||||||
|
* @param {Function} deps.onView (row) → void — navigate to view page
|
||||||
|
* @param {Function} deps.onEdit (row) → void — navigate to edit page
|
||||||
|
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
||||||
|
*/
|
||||||
|
export function buildRowActions({ onRestore }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "restore",
|
||||||
|
label: "Restore",
|
||||||
|
className: "text-emerald-600 focus:text-emerald-600",
|
||||||
|
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onRestore(row),
|
||||||
|
hidden: (row) => row.is_active,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// config/assets/archive/selection.config.jsx
|
||||||
|
import { Download, ArchiveRestore } from "lucide-react";
|
||||||
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
|
export function buildSelectionActions({ exportConfig, onSingleRestore, onBulkRestore, getTableInstance }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "export-selected",
|
||||||
|
label: "Export",
|
||||||
|
icon: <Download className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (rows, table) =>
|
||||||
|
exportTableToExcel({
|
||||||
|
...exportConfig,
|
||||||
|
selectedRows: rows,
|
||||||
|
tableInstance: table ?? getTableInstance(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "restore-selected",
|
||||||
|
label: "Restore",
|
||||||
|
icon: <ArchiveRestore className="h-3.5 w-3.5" />,
|
||||||
|
className: "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-700",
|
||||||
|
onClick: (rows) => {
|
||||||
|
const ids = rows.map((r) => r.asset_id);
|
||||||
|
ids.length === 1
|
||||||
|
? onSingleRestore(rows[0])
|
||||||
|
: onBulkRestore(ids);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
|
||||||
|
import { RefreshCw, Download, Plus, Archive } from "lucide-react";
|
||||||
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} deps
|
||||||
|
* @param {Function} deps.fetchAssets
|
||||||
|
* @param {Object} deps.pagination
|
||||||
|
* @param {Object} deps.exportConfig
|
||||||
|
* @param {Function} deps.navigate
|
||||||
|
* @param {Function} deps.getFilters
|
||||||
|
* @param {Function} deps.getSort
|
||||||
|
* @param {Function} deps.getTableInstance
|
||||||
|
*/
|
||||||
|
export function buildToolbarActions({ fetchAssets, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "refresh",
|
||||||
|
type: "button",
|
||||||
|
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||||
|
label: "Refresh",
|
||||||
|
onClick: () =>
|
||||||
|
fetchAssets({
|
||||||
|
page: 1,
|
||||||
|
limit: pagination.limit,
|
||||||
|
filters: getFilters(),
|
||||||
|
sort: getSort(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "export",
|
||||||
|
type: "button",
|
||||||
|
icon: <Download className="h-3.5 w-3.5" />,
|
||||||
|
label: "Export",
|
||||||
|
onClick: (table) =>
|
||||||
|
exportTableToExcel({
|
||||||
|
...exportConfig,
|
||||||
|
tableInstance: table ?? getTableInstance(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// config/columns.config.jsx
|
||||||
|
// Column definitions and pinning config for the Users table.
|
||||||
|
|
||||||
|
import { buildColumns } from "@/utils/table.util";
|
||||||
|
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||||
|
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
|
||||||
|
export const columnPinning = {
|
||||||
|
right: ["actions"],
|
||||||
|
left: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||||
|
const cellOverrides = {
|
||||||
|
// memberCount: (info) => {
|
||||||
|
// const count = parseInt(info.getValue() ?? 0, 10);
|
||||||
|
// return (
|
||||||
|
// <div className="flex items-center gap-1.5">
|
||||||
|
// <Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
// <Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||||
|
// {count} {count === 1 ? "member" : "members"}
|
||||||
|
// </Badge>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the full column array for the Users table.
|
||||||
|
*
|
||||||
|
* @param {Array} attributes Field definitions from the server (drives data columns)
|
||||||
|
* @param {Array} rowActions Row-level kebab action definitions
|
||||||
|
* @returns {Array} TanStack column definitions
|
||||||
|
*/
|
||||||
|
export function buildDataColumns(attributes, rowActions) {
|
||||||
|
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||||
|
|
||||||
|
return [
|
||||||
|
buildSelectionColumn(),
|
||||||
|
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||||
|
buildRowActionsColumn(rowActions, { dropdownLabel: "Asset Actions" }),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// modules/admin/config/assets/rowActions.config.jsx
|
||||||
|
|
||||||
|
import { Eye, Pencil, Archive } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} deps
|
||||||
|
* @param {Function} deps.onView (row) → void — navigate to view page
|
||||||
|
* @param {Function} deps.onEdit (row) → void — navigate to edit page
|
||||||
|
* @param {Function} deps.onArchive (row) → void — open archive dialog
|
||||||
|
*/
|
||||||
|
export function buildRowActions({ onView, onEdit, onArchive }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "view",
|
||||||
|
label: "View",
|
||||||
|
icon: <Eye className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onView(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "edit",
|
||||||
|
label: "Edit",
|
||||||
|
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (row) => onEdit(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "archive",
|
||||||
|
label: "Archive",
|
||||||
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
|
className: "text-destructive",
|
||||||
|
onClick: (row) => onArchive(row),
|
||||||
|
separator: true
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// config/selection.config.jsx
|
||||||
|
import { Download, Archive, Trash2 } from "lucide-react";
|
||||||
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} deps
|
||||||
|
* @param {Object} deps.exportConfig
|
||||||
|
* @param {Function} deps.onArchive (row) → void single archive dialog
|
||||||
|
* @param {Function} deps.onArchiveMany (ids[]) → void bulk archive dialog
|
||||||
|
* @param {Function} deps.getTableInstance
|
||||||
|
*/
|
||||||
|
export function buildSelectionActions({ exportConfig, onArchive, onArchiveMany, getTableInstance, }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "export-selected",
|
||||||
|
label: "Export",
|
||||||
|
icon: <Download className="h-3.5 w-3.5" />,
|
||||||
|
onClick: (rows, table) =>
|
||||||
|
exportTableToExcel({
|
||||||
|
...exportConfig,
|
||||||
|
selectedRows: rows,
|
||||||
|
tableInstance: table ?? getTableInstance(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "archive-selected",
|
||||||
|
label: "Archive",
|
||||||
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
|
className: "text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive",
|
||||||
|
onClick: (rows) => {
|
||||||
|
const ids = rows.map((r) => r.asset_id);
|
||||||
|
ids.length === 1
|
||||||
|
? onArchive(rows[0])
|
||||||
|
: onArchiveMany(ids);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
|
||||||
|
import { RefreshCw, Download, Plus, Archive } from "lucide-react";
|
||||||
|
import { exportTableToExcel } from "@/utils/excel.util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} deps
|
||||||
|
* @param {Function} deps.fetchAssets
|
||||||
|
* @param {Object} deps.pagination
|
||||||
|
* @param {Object} deps.exportConfig
|
||||||
|
* @param {Function} deps.navigate
|
||||||
|
* @param {Function} deps.getFilters
|
||||||
|
* @param {Function} deps.getSort
|
||||||
|
* @param {Function} deps.getTableInstance
|
||||||
|
*/
|
||||||
|
export function buildToolbarActions({ fetchAssets, pagination, exportConfig, navigate, getFilters, getSort, getTableInstance }) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "refresh",
|
||||||
|
type: "button",
|
||||||
|
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||||
|
label: "Refresh",
|
||||||
|
onClick: () =>
|
||||||
|
fetchAssets({
|
||||||
|
page: 1,
|
||||||
|
limit: pagination.limit,
|
||||||
|
filters: getFilters(),
|
||||||
|
sort: getSort(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "export",
|
||||||
|
type: "button",
|
||||||
|
icon: <Download className="h-3.5 w-3.5" />,
|
||||||
|
label: "Export",
|
||||||
|
onClick: (table) =>
|
||||||
|
exportTableToExcel({
|
||||||
|
...exportConfig,
|
||||||
|
tableInstance: table ?? getTableInstance(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "add-asset",
|
||||||
|
type: "button",
|
||||||
|
icon: <Plus className="h-3.5 w-3.5" />,
|
||||||
|
label: "Add Asset",
|
||||||
|
variant: "default",
|
||||||
|
className: "text-primary-foreground",
|
||||||
|
onClick: () => navigate("add"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "archived-users",
|
||||||
|
type: "button",
|
||||||
|
icon: <Archive className="h-3.5 w-3.5" />,
|
||||||
|
label: "Archived Assets",
|
||||||
|
variant: "secondary",
|
||||||
|
className: "border border-border",
|
||||||
|
onClick: () => navigate("/admin/assets/archived"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ export function buildToolbarActions({ fetchGroups, pagination, exportConfig, onA
|
|||||||
label: "Archived Groups",
|
label: "Archived Groups",
|
||||||
variant: "secondary",
|
variant: "secondary",
|
||||||
className: "border border-border",
|
className: "border border-border",
|
||||||
onClick: () => navigate("/admin/users/groups/archived"),
|
onClick: () => navigate("/admin/groups/archived"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@ export function buildToolbarActions({ fetchUsers, pagination, exportConfig, navi
|
|||||||
label: "Archived Users",
|
label: "Archived Users",
|
||||||
variant: "secondary",
|
variant: "secondary",
|
||||||
className: "border border-border",
|
className: "border border-border",
|
||||||
onClick: () => navigate("/admin/users/all/archived"),
|
onClick: () => navigate("/admin/users/archived"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,6 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { Toaster } from "sonner"
|
import { Toaster } from "sonner"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
import AdminSideTabs from "../components/AdminSideTabs"
|
|
||||||
import UserMenu from "@/components/generic/UserMenu"
|
import UserMenu from "@/components/generic/UserMenu"
|
||||||
import { ROLE_CONFIG } from "@/data/profile.data"
|
import { ROLE_CONFIG } from "@/data/profile.data"
|
||||||
|
|
||||||
@@ -78,9 +77,6 @@ const AdminLayout = () => {
|
|||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="side-tabs">
|
|
||||||
<AdminSideTabs />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── All admin contexts live here, scoped to admin routes only ── */}
|
{/* ─── All admin contexts live here, scoped to admin routes only ── */}
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { Outlet } from 'react-router-dom'
|
import { Outlet } from 'react-router-dom'
|
||||||
|
|
||||||
const UserManagementLayout = () => {
|
const NavigationLayout = () => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
@@ -9,4 +9,4 @@ const UserManagementLayout = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default UserManagementLayout
|
export default NavigationLayout
|
||||||
@@ -1,9 +1,54 @@
|
|||||||
// modules/admin/pages/UsersDashboard.jsx
|
// modules/admin/pages/UsersDashboard.jsx
|
||||||
|
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/custom/original-tabs";
|
||||||
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||||
|
import DashboardGrid from "@/components/generic/DashboardGrid";
|
||||||
|
import { ADMIN_SECTIONS } from "@/data/adminTiles.data";
|
||||||
|
|
||||||
|
function scrollTo(sectionId) {
|
||||||
|
document.getElementById(sectionId)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
AdminDashboard
|
{/* ── Sticky tab bar — driven by the same array ── */}
|
||||||
|
<div className="sticky top-0 z-10 bg-background border-b">
|
||||||
|
<Tabs defaultValue="">
|
||||||
|
<ScrollArea className="max-w-full overflow-x-auto w-full">
|
||||||
|
<TabsList className="bg-background rounded-none justify-start mx-2 my-1 flex gap-1">
|
||||||
|
{ADMIN_SECTIONS.map((s) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={s.id}
|
||||||
|
value={s.id}
|
||||||
|
onClick={() => s.id === ADMIN_SECTIONS[0].id
|
||||||
|
? window.scrollTo({ top: 0, behavior: "smooth" })
|
||||||
|
: scrollTo(s.id)
|
||||||
|
}
|
||||||
|
className="data-[state=active]:bg-muted data-[state=active]:shadow-none hover:bg-muted text-muted-foreground data-[state=active]:text-foreground"
|
||||||
|
>
|
||||||
|
{s.tab}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
<ScrollBar orientation="horizontal" />
|
||||||
|
</ScrollArea>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sections — same array, one DashboardGrid per entry ── */}
|
||||||
|
{ADMIN_SECTIONS.map((s) => (
|
||||||
|
<div key={s.id} id={s.id} className="scroll-mt-12 min-h-64">
|
||||||
|
{s.tiles.length > 0 ? (
|
||||||
|
<DashboardGrid sections={[s]} />
|
||||||
|
) : (
|
||||||
|
<div className="px-4 lg:container lg:mx-auto py-10">
|
||||||
|
<h1 className="text-2xl font-medium tracking-tighter mb-1">{s.title}</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">No items yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
// modules/admin/pages/assets/AddAsset.jsx
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image } from "lucide-react";
|
||||||
|
|
||||||
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
// ─── Derive file_type from MIME type ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function resolveFileType(mimeType = "") {
|
||||||
|
if (mimeType.startsWith("video/")) return "video";
|
||||||
|
if (mimeType.startsWith("image/")) return "image";
|
||||||
|
if (mimeType.startsWith("application/") || mimeType.startsWith("text/")) return "document";
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
display_name: z.string().min(1, "Display name is required."),
|
||||||
|
description: z.string().optional(),
|
||||||
|
is_public: z.enum(["true", "false"]),
|
||||||
|
storage_provider: z.enum(["chibisafe", "local", "s3"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── File type icon ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FileTypeIcon({ mimeType = "" }) {
|
||||||
|
if (mimeType.startsWith("video/")) return <FileVideo className="h-10 w-10 text-blue-400" />;
|
||||||
|
if (mimeType.startsWith("image/")) return <Image className="h-10 w-10 text-green-400" />;
|
||||||
|
return <FileText className="h-10 w-10 text-orange-400" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Drop zone ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DropZone({ label, accept, file, onFile, onClear, error }) {
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) onFile(f); }}
|
||||||
|
onClick={() => !file && inputRef.current?.click()}
|
||||||
|
className={[
|
||||||
|
"rounded-lg border-2 border-dashed transition-colors cursor-pointer",
|
||||||
|
error ? "border-destructive/60 bg-destructive/5" : "",
|
||||||
|
file ? "border-border bg-muted/30 cursor-default" : "",
|
||||||
|
!file && !error ? "border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { if (e.target.files[0]) onFile(e.target.files[0]); }}
|
||||||
|
/>
|
||||||
|
{file ? (
|
||||||
|
<div className="flex items-center gap-3 p-4">
|
||||||
|
<FileTypeIcon mimeType={file.type} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{(file.size / 1024).toFixed(1)} KB · {file.type}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button" variant="ghost" size="icon"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClear(); }}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-10 px-4">
|
||||||
|
<UploadCloud className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Drag & drop or <span className="text-primary font-medium">browse</span> to upload
|
||||||
|
<br />
|
||||||
|
<span className="text-xs">{label}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Field error ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FieldError({ message }) {
|
||||||
|
if (!message) return null;
|
||||||
|
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function AddAsset() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { uploadAsset, loading } = useAssets();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const fileRef = useRef(null);
|
||||||
|
const thumbnailRef = useRef(null);
|
||||||
|
const [thumbKey, setThumbKey] = useState(0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
setError,
|
||||||
|
clearErrors,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
display_name: "",
|
||||||
|
description: "",
|
||||||
|
is_public: "false",
|
||||||
|
storage_provider: "chibisafe",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const file = watch("_file");
|
||||||
|
const isVideo = file?.type?.startsWith("video/");
|
||||||
|
|
||||||
|
// ── Auto-derive file_type from MIME ───────────────────────────────────────
|
||||||
|
const fileType = file ? resolveFileType(file.type) : null;
|
||||||
|
|
||||||
|
const setFile = (f) => {
|
||||||
|
fileRef.current = f;
|
||||||
|
setValue("_file", f);
|
||||||
|
if (!watch("display_name")) setValue("display_name", f.name);
|
||||||
|
clearErrors("_file");
|
||||||
|
};
|
||||||
|
|
||||||
|
const setThumbnail = (f) => {
|
||||||
|
thumbnailRef.current = f;
|
||||||
|
setValue("_thumbnail", f);
|
||||||
|
clearErrors("_thumbnail");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data) => {
|
||||||
|
let hasFileError = false;
|
||||||
|
|
||||||
|
if (!fileRef.current) {
|
||||||
|
setError("_file", { message: "A file is required." });
|
||||||
|
hasFileError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVideo && !thumbnailRef.current) {
|
||||||
|
setError("_thumbnail", { message: "A thumbnail is required for video uploads." });
|
||||||
|
hasFileError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasFileError) return;
|
||||||
|
|
||||||
|
const result = await uploadAsset({
|
||||||
|
file: fileRef.current,
|
||||||
|
thumbnail: thumbnailRef.current ?? undefined,
|
||||||
|
display_name: data.display_name,
|
||||||
|
description: data.description ?? "",
|
||||||
|
file_type: fileType,
|
||||||
|
is_public: data.is_public === "true",
|
||||||
|
storage_provider: data.storage_provider,
|
||||||
|
createdBy: user?.user_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result) navigate(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Add Asset</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Upload a new file to the asset library.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||||
|
|
||||||
|
{/* ── File ── */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>File <span className="text-destructive">*</span></Label>
|
||||||
|
<DropZone
|
||||||
|
label="Images, videos, documents"
|
||||||
|
accept="image/*,video/*,application/*,text/*"
|
||||||
|
file={fileRef.current}
|
||||||
|
onFile={setFile}
|
||||||
|
onClear={() => {
|
||||||
|
fileRef.current = null;
|
||||||
|
setValue("_file", null);
|
||||||
|
setValue("display_name", "");
|
||||||
|
clearErrors("_file");
|
||||||
|
}}
|
||||||
|
error={errors._file?.message}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors._file?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Thumbnail (video only) ── */}
|
||||||
|
{isVideo && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Thumbnail <span className="text-destructive">*</span></Label>
|
||||||
|
<DropZone
|
||||||
|
key={thumbKey}
|
||||||
|
label="JPEG, PNG (video thumbnail)"
|
||||||
|
accept="image/*"
|
||||||
|
file={thumbnailRef.current}
|
||||||
|
onFile={setThumbnail}
|
||||||
|
onClear={() => {
|
||||||
|
thumbnailRef.current = null;
|
||||||
|
setValue("_thumbnail", null);
|
||||||
|
clearErrors("_thumbnail");
|
||||||
|
setThumbKey((k) => k + 1);
|
||||||
|
}}
|
||||||
|
error={errors._thumbnail?.message}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors._thumbnail?.message} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Display Name ── */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="display_name">
|
||||||
|
Display Name <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="display_name"
|
||||||
|
placeholder="Friendly name for this asset"
|
||||||
|
{...register("display_name")}
|
||||||
|
/>
|
||||||
|
<FieldError message={errors.display_name?.message} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Description ── */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="Optional description"
|
||||||
|
rows={3}
|
||||||
|
{...register("description")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── File Type · Access · Storage (one row) ── */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
|
||||||
|
{/* File Type — auto-derived, disabled */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>File Type</Label>
|
||||||
|
<Select value={fileType ?? ""} disabled>
|
||||||
|
<SelectTrigger className="disabled:opacity-60 disabled:cursor-not-allowed">
|
||||||
|
<SelectValue placeholder="Auto-detected" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="avatar">Avatar</SelectItem>
|
||||||
|
<SelectItem value="image">Image</SelectItem>
|
||||||
|
<SelectItem value="video">Video</SelectItem>
|
||||||
|
<SelectItem value="document">Document</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">Detected from file</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Access */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Access</Label>
|
||||||
|
<Controller
|
||||||
|
name="is_public"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="false">Private</SelectItem>
|
||||||
|
<SelectItem value="true">Public</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Storage Provider */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Storage</Label>
|
||||||
|
<Controller
|
||||||
|
name="storage_provider"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="chibisafe">Chibisafe</SelectItem>
|
||||||
|
<SelectItem value="local">Local</SelectItem>
|
||||||
|
<SelectItem value="s3">S3</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Actions ── */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={loading}>
|
||||||
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
|
Upload Asset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { House } from "lucide-react";
|
||||||
|
|
||||||
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
|
import ArchivedAssetsTable from "../../components/assets/ArchivedAssetsTable";
|
||||||
|
|
||||||
|
export default function ArchivedAssetList() {
|
||||||
|
const items = [
|
||||||
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
|
{ label: "Assets", to: `/admin/assets` },
|
||||||
|
{ label: "Archived" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-muted/60 h-full">
|
||||||
|
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||||
|
<div className="flex flex-col gap-2 my-6">
|
||||||
|
<AppBreadcrumb items={items} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<ArchivedAssetsTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { House } from "lucide-react";
|
||||||
|
|
||||||
|
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||||
|
import AssetsTable from "../../components/assets/AssetsTable";
|
||||||
|
|
||||||
|
export default function AssetList() {
|
||||||
|
const items = [
|
||||||
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
|
{ label: "Assets" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-muted/60 h-full">
|
||||||
|
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4">
|
||||||
|
<div className="flex flex-col gap-2 my-6 ">
|
||||||
|
<AppBreadcrumb items={items} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<AssetsTable />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
// modules/admin/pages/assets/EditAsset.jsx
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { ArrowLeft, UploadCloud, X, FileVideo, FileText, Image, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
display_name: z.string().min(1, "Display name is required."),
|
||||||
|
description: z.string().optional(),
|
||||||
|
is_public: z.enum(["true", "false"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function FileTypeIcon({ mimeType = "" }) {
|
||||||
|
if (mimeType?.startsWith("video/")) return <FileVideo className="h-10 w-10 text-blue-400" />;
|
||||||
|
if (mimeType?.startsWith("image/")) return <Image className="h-10 w-10 text-green-400" />;
|
||||||
|
return <FileText className="h-10 w-10 text-orange-400" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldError({ message }) {
|
||||||
|
if (!message) return null;
|
||||||
|
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!bytes) return "—";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Thumbnail Drop Zone ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ThumbnailDropZone({ currentUrl, newFile, onFile, onClear, error }) {
|
||||||
|
console.log(currentUrl)
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
const preview = newFile
|
||||||
|
? URL.createObjectURL(newFile)
|
||||||
|
: currentUrl ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) onFile(f); }}
|
||||||
|
onClick={() => !newFile && inputRef.current?.click()}
|
||||||
|
className={[
|
||||||
|
"relative rounded-lg border-2 border-dashed transition-colors overflow-hidden",
|
||||||
|
error ? "border-destructive/60 bg-destructive/5" : "",
|
||||||
|
newFile ? "border-border cursor-default" : "",
|
||||||
|
!newFile && !error ? "border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 cursor-pointer" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { if (e.target.files[0]) onFile(e.target.files[0]); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{preview ? (
|
||||||
|
<div className="relative">
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt="Thumbnail preview"
|
||||||
|
className="w-full object-contain"
|
||||||
|
/>
|
||||||
|
{/* overlay actions */}
|
||||||
|
<div className="absolute inset-0 bg-black/40 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={(e) => { e.stopPropagation(); inputRef.current?.click(); }}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Replace
|
||||||
|
</Button>
|
||||||
|
{newFile && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClear(); }}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5 mr-1.5" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{newFile && (
|
||||||
|
<Badge className="absolute top-2 right-2 bg-primary text-primary-foreground text-xs">
|
||||||
|
New
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-10 px-4">
|
||||||
|
<UploadCloud className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
Drag & drop or <span className="text-primary font-medium">browse</span> to upload
|
||||||
|
<br />
|
||||||
|
<span className="text-xs">JPEG, PNG</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function EditAsset() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { assetId } = useParams();
|
||||||
|
const { fetchAsset, updateAsset, loading } = useAssets();
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const thumbnailRef = useRef(null);
|
||||||
|
const [thumbKey, setThumbKey] = useState(0);
|
||||||
|
const [initializing, setInitializing] = useState(true);
|
||||||
|
const [asset, setAsset] = useState(null); // ← local, always fresh
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors, isDirty },
|
||||||
|
} = useForm({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
display_name: "",
|
||||||
|
description: "",
|
||||||
|
is_public: "false",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const res = await fetchAsset(assetId);
|
||||||
|
const fetched = res?.data?.data ?? null;
|
||||||
|
if (!fetched) return;
|
||||||
|
setAsset(fetched);
|
||||||
|
reset({
|
||||||
|
display_name: fetched.display_name ?? "",
|
||||||
|
description: fetched.description ?? "",
|
||||||
|
is_public: fetched.is_public ? "true" : "false",
|
||||||
|
});
|
||||||
|
setInitializing(false);
|
||||||
|
})();
|
||||||
|
}, [assetId]);
|
||||||
|
|
||||||
|
const isVideo = asset?.file_type === "video";
|
||||||
|
const hasThumbnailChange = !!thumbnailRef.current;
|
||||||
|
|
||||||
|
const onSubmit = async (data) => {
|
||||||
|
const result = await updateAsset(
|
||||||
|
assetId,
|
||||||
|
{
|
||||||
|
display_name: data.display_name,
|
||||||
|
description: data.description ?? "",
|
||||||
|
is_public: data.is_public === "true",
|
||||||
|
updatedBy: user?.user_id,
|
||||||
|
...(isVideo && hasThumbnailChange ? { is_thumbnail: true } : {}),
|
||||||
|
},
|
||||||
|
hasThumbnailChange ? thumbnailRef.current : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) return;
|
||||||
|
navigate(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (initializing) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Spinner className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-6">
|
||||||
|
<p className="text-sm text-muted-foreground">Asset not found.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold">Edit Asset</h1>
|
||||||
|
<p className="text-sm text-muted-foreground truncate max-w-sm">
|
||||||
|
{asset.original_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
|
||||||
|
{/* ── Current file info (read-only) ── */}
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">
|
||||||
|
File Info
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileTypeIcon mimeType={asset.mime_type} />
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<p className="text-sm font-medium truncate">{asset.original_name}</p>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge variant="secondary" className="text-xs">{asset.file_type}</Badge>
|
||||||
|
<Badge variant="outline" className="text-xs">{asset.extension?.toUpperCase()}</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">{formatBytes(asset.file_size)}</span>
|
||||||
|
{asset.resolution && (
|
||||||
|
<span className="text-xs text-muted-foreground">{asset.resolution}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Editable fields ── */}
|
||||||
|
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||||
|
|
||||||
|
{/* ── Thumbnail (video or image) ── */}
|
||||||
|
{(isVideo || asset.file_type === "image" || asset.file_type === "avatar") && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>
|
||||||
|
Thumbnail
|
||||||
|
{isVideo && <span className="text-muted-foreground text-xs ml-1">(optional replacement)</span>}
|
||||||
|
</Label>
|
||||||
|
<ThumbnailDropZone
|
||||||
|
key={thumbKey}
|
||||||
|
currentUrl={asset.file_type === "video" ? asset.thumbnail_url : asset.file_url}
|
||||||
|
newFile={thumbnailRef.current}
|
||||||
|
onFile={(f) => {
|
||||||
|
thumbnailRef.current = f;
|
||||||
|
setThumbKey((k) => k + 1);
|
||||||
|
}}
|
||||||
|
onClear={() => {
|
||||||
|
thumbnailRef.current = null;
|
||||||
|
setThumbKey((k) => k + 1);
|
||||||
|
}}
|
||||||
|
error={null}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{/* ── Access ── */}
|
||||||
|
<div className="space-y-1.5 max-w-[200px]">
|
||||||
|
<Label>Access</Label>
|
||||||
|
<Controller
|
||||||
|
name="is_public"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="false">Private</SelectItem>
|
||||||
|
<SelectItem value="true">Public</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Actions ── */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || (!isDirty && !hasThumbnailChange)}
|
||||||
|
>
|
||||||
|
{loading && <Spinner className="h-4 w-4 mr-2" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// modules/admin/pages/assets/ViewDocumentAsset.jsx
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { ArrowLeft, Lock, Globe, FileText } from "lucide-react";
|
||||||
|
|
||||||
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
function MetaRow({ label, value }) {
|
||||||
|
if (!value && value !== 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 py-2">
|
||||||
|
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
||||||
|
<span className="text-sm font-medium break-all">{String(value)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEWABLE = ["pdf", "txt", "html", "htm", "csv", "md"];
|
||||||
|
|
||||||
|
export default function ViewDocumentAsset() {
|
||||||
|
const { assetId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (assetId) fetchAsset(assetId);
|
||||||
|
}, [assetId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Spinner className="size-8" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedAsset) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||||
|
<p>Asset not found.</p>
|
||||||
|
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = selectedAsset;
|
||||||
|
const canPreview = PREVIEWABLE.includes((a.extension ?? "").toLowerCase());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|
||||||
|
{/* ── Document preview ── */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
{canPreview && a.file_url ? (
|
||||||
|
<div className="rounded-lg border overflow-hidden bg-white" style={{ height: 600 }}>
|
||||||
|
<iframe
|
||||||
|
src={a.file_url}
|
||||||
|
title={a.display_name ?? a.original_name}
|
||||||
|
className="w-full h-full"
|
||||||
|
// sandbox="allow-scripts allow-same-origin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border bg-muted/30 flex flex-col items-center justify-center gap-4 py-20">
|
||||||
|
<FileText className="h-16 w-16 text-muted-foreground/40" />
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Preview not available for this file type.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Metadata panel ── */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
|
||||||
|
<MetaRow label="Original Name" value={a.original_name} />
|
||||||
|
<MetaRow label="Extension" value={a.extension} />
|
||||||
|
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
|
||||||
|
<MetaRow label="MIME Type" value={a.mime_type} />
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
||||||
|
<MetaRow label="Provider" value={a.storage_provider} />
|
||||||
|
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||||
|
<MetaRow label="Key" value={a.storage_key} />
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
{a.is_public
|
||||||
|
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||||
|
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<MetaRow label="Access Level" value={a.access_level} />
|
||||||
|
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||||
|
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
||||||
|
<MetaRow label="Created By" value={a.createdBy} />
|
||||||
|
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
|
||||||
|
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{a.description && (
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
||||||
|
<p className="text-sm text-foreground">{a.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// modules/admin/pages/assets/ViewImageAsset.jsx
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { ArrowLeft, Lock, Globe } from "lucide-react";
|
||||||
|
|
||||||
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
function MetaRow({ label, value }) {
|
||||||
|
if (!value && value !== 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 py-2">
|
||||||
|
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
||||||
|
<span className="text-sm font-medium break-all">{String(value)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewImageAsset() {
|
||||||
|
const { assetId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (assetId) fetchAsset(assetId);
|
||||||
|
}, [assetId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Spinner className="size-8" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedAsset) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||||
|
<p>Asset not found.</p>
|
||||||
|
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = selectedAsset;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|
||||||
|
{/* ── Image preview ── */}
|
||||||
|
<div className="lg:col-span-3 rounded-lg border bg-muted/30 overflow-hidden flex items-center justify-center min-h-64">
|
||||||
|
{a.file_url ? (
|
||||||
|
<img
|
||||||
|
src={a.file_url}
|
||||||
|
alt={a.display_name ?? a.original_name}
|
||||||
|
className="max-w-full max-h-[520px] object-contain"
|
||||||
|
draggable={false}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">No preview available.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Metadata panel ── */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">File Info</p>
|
||||||
|
<MetaRow label="Original Name" value={a.original_name} />
|
||||||
|
<MetaRow label="Extension" value={a.extension} />
|
||||||
|
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / 1024).toFixed(1)} KB` : null} />
|
||||||
|
<MetaRow label="Resolution" value={a.resolution} />
|
||||||
|
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
||||||
|
<MetaRow label="Provider" value={a.storage_provider} />
|
||||||
|
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||||
|
<MetaRow label="Key" value={a.storage_key} />
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
{a.is_public
|
||||||
|
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||||
|
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<MetaRow label="Access Level" value={a.access_level} />
|
||||||
|
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||||
|
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
||||||
|
<MetaRow label="Created By" value={a.createdBy} />
|
||||||
|
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
|
||||||
|
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{a.description && (
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
||||||
|
<p className="text-sm text-foreground">{a.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import { ArrowLeft, Lock, Globe } from "lucide-react";
|
||||||
|
|
||||||
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
function MetaRow({ label, value }) {
|
||||||
|
if (!value && value !== 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3 py-2">
|
||||||
|
<span className="text-muted-foreground text-sm w-36 shrink-0">{label}</span>
|
||||||
|
<span className="text-sm font-medium break-all">{String(value)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!seconds && seconds !== 0) return null;
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
const s = Math.floor(seconds % 60);
|
||||||
|
return [h > 0 ? String(h).padStart(2, "0") : null, String(m).padStart(2, "0"), String(s).padStart(2, "0")]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ViewVideoAsset() {
|
||||||
|
const { assetId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (assetId) fetchAsset(assetId);
|
||||||
|
}, [assetId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Spinner className="size-8" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedAsset) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-96 gap-3 text-muted-foreground">
|
||||||
|
<p>Asset not found.</p>
|
||||||
|
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = selectedAsset;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-6 space-y-6">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|
||||||
|
{/* ── Video player ── */}
|
||||||
|
<div className="lg:col-span-3 space-y-3">
|
||||||
|
<div className="rounded-lg border bg-black overflow-hidden aspect-video flex items-center justify-center">
|
||||||
|
{a.file_url ? (
|
||||||
|
<video
|
||||||
|
key={a.file_url}
|
||||||
|
controls
|
||||||
|
controlsList="nodownload" // ← hides download button in browser controls
|
||||||
|
disablePictureInPicture // ← hides PiP button
|
||||||
|
onContextMenu={(e) => e.preventDefault()} // ← disables right-click save
|
||||||
|
className="w-full h-full"
|
||||||
|
poster={a.thumbnail_url ?? undefined}
|
||||||
|
>
|
||||||
|
<source src={a.file_url} type={a.mime_type ?? "video/mp4"} />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">No video source available.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail strip */}
|
||||||
|
{a.thumbnail_url && (
|
||||||
|
<div className="rounded-lg border overflow-hidden bg-muted/30">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground px-3 py-2">Thumbnail</p>
|
||||||
|
<img
|
||||||
|
src={a.thumbnail_url}
|
||||||
|
alt="Thumbnail"
|
||||||
|
className="w-full max-h-40 object-cover"
|
||||||
|
draggable={false}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Metadata panel ── */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<div className="rounded-lg border bg-card p-4 space-y-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Video Info</p>
|
||||||
|
<MetaRow label="Original Name" value={a.original_name} />
|
||||||
|
<MetaRow label="Extension" value={a.extension} />
|
||||||
|
<MetaRow label="File Size" value={a.file_size ? `${(a.file_size / (1024 * 1024)).toFixed(2)} MB` : null} />
|
||||||
|
<MetaRow label="Resolution" value={a.resolution} />
|
||||||
|
<MetaRow label="Dimensions" value={a.width && a.height ? `${a.width} × ${a.height}` : null} />
|
||||||
|
<MetaRow label="Duration" value={formatDuration(a.duration)} />
|
||||||
|
<MetaRow label="Frame Rate" value={a.frame_rate ? `${a.frame_rate} fps` : null} />
|
||||||
|
<MetaRow label="Bitrate" value={a.bitrate ? `${a.bitrate} kbps` : null} />
|
||||||
|
<MetaRow label="Video Codec" value={a.video_codec} />
|
||||||
|
<MetaRow label="Audio Codec" value={a.audio_codec} />
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Storage</p>
|
||||||
|
<MetaRow label="Provider" value={a.storage_provider} />
|
||||||
|
<MetaRow label="Bucket" value={a.storage_bucket} />
|
||||||
|
<MetaRow label="Key" value={a.storage_key} />
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Access</p>
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
{a.is_public
|
||||||
|
? <><Globe className="h-3.5 w-3.5 text-green-500" /><Badge variant="outline" className="text-green-600 border-green-400">Public</Badge></>
|
||||||
|
: <><Lock className="h-3.5 w-3.5 text-yellow-500" /><Badge variant="outline" className="text-yellow-600 border-yellow-400">Private</Badge></>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<MetaRow label="Access Level" value={a.access_level} />
|
||||||
|
<MetaRow label="Owner Type" value={a.owner_type} />
|
||||||
|
<MetaRow label="Owner ID" value={a.owner_id} />
|
||||||
|
<Separator className="my-2" />
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Timestamps</p>
|
||||||
|
<MetaRow label="Created By" value={a.createdBy} />
|
||||||
|
<MetaRow label="Created" value={a.createdAt ? new Date(a.createdAt).toLocaleString() : null} />
|
||||||
|
<MetaRow label="Modified" value={a.updatedAt ? new Date(a.updatedAt).toLocaleString() : null} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{a.description && (
|
||||||
|
<div className="rounded-lg border bg-card p-4">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Description</p>
|
||||||
|
<p className="text-sm text-foreground">{a.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import ArchiveGroupTable from "../../components/user_groups/ArchiveGroupTable";
|
|||||||
|
|
||||||
export default function ArchivedGroupList() {
|
export default function ArchivedGroupList() {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
{ label: "User Group", to: `/admin/users/groups` },
|
{ label: "User Group", to: `/admin/groups` },
|
||||||
{ label: "Archived" },
|
{ label: "Archived" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import GroupTable from "../../components/user_groups/GroupTable";
|
|||||||
|
|
||||||
export default function GroupList() {
|
export default function GroupList() {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
{ label: "User Groups" },
|
{ label: "User Groups" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ export default function ViewGroup() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin/users" },
|
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||||
{ label: "User Groups", to: "/admin/users/groups" },
|
{ label: "User Groups", to: "/admin/groups" },
|
||||||
{ label: group?.name ?? "View Group" },
|
{ label: group?.name ?? "View Group" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import ArchiveUserTable from "../../components/users/ArchiveUserTable";
|
|||||||
|
|
||||||
export default function ArchivedUserList() {
|
export default function ArchivedUserList() {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
{ label: "Users", to: `/admin/users/all` },
|
{ label: "Users", to: `/admin/users` },
|
||||||
{ label: "Archived" },
|
{ label: "Archived" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export default function EditUser() {
|
|||||||
{/* ─── Header ──────────────────────────────────────────────────────── */}
|
{/* ─── Header ──────────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/users/all`)}>
|
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(`/admin/users`)}>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { USER_MANAGEMENT } from "@/data/adminTiles.data";
|
|
||||||
import DashboardGrid from "@/components/generic/DashboardGrid";
|
|
||||||
|
|
||||||
export default function UserDashboard() {
|
|
||||||
return <DashboardGrid sections={USER_MANAGEMENT} />
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import UsersTable from "../../components/users/UserTable";
|
|||||||
|
|
||||||
export default function UserList() {
|
export default function UserList() {
|
||||||
const items = [
|
const items = [
|
||||||
{ label: "Home", icon: <House className="size-4" />, to: `/admin/users` },
|
{ label: "Home", icon: <House className="size-4" />, to: `/admin` },
|
||||||
{ label: "Users" },
|
{ label: "Users" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function ViewUser() {
|
|||||||
{/* ─── Header ──────────────────────────────────────────────────────── */}
|
{/* ─── Header ──────────────────────────────────────────────────────── */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users/all`)}>
|
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users`)}>
|
||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,15 +3,11 @@ import ProtectedRoute from '../../../routes/ProtectedRoute'
|
|||||||
|
|
||||||
// Layouts
|
// Layouts
|
||||||
import AdminLayout from '../layouts/AdminLayout'
|
import AdminLayout from '../layouts/AdminLayout'
|
||||||
import UserManagementLayout from '../layouts/UserManagementLayout'
|
|
||||||
|
|
||||||
// Global Pages
|
// Global Pages
|
||||||
import AdminDashboard from '../pages/AdminDashboard'
|
import AdminDashboard from '../pages/AdminDashboard'
|
||||||
import ProfilePage from '@/components/generic/Profile'
|
import ProfilePage from '@/components/generic/Profile'
|
||||||
|
|
||||||
// Specific Pages
|
|
||||||
import UsersDashboard from '../pages/users/UserDashboard'
|
|
||||||
|
|
||||||
import UserList from '../pages/users/UserList'
|
import UserList from '../pages/users/UserList'
|
||||||
import AddUser from '../pages/users/AddStaffUser'
|
import AddUser from '../pages/users/AddStaffUser'
|
||||||
import ViewUser from '../pages/users/ViewUser'
|
import ViewUser from '../pages/users/ViewUser'
|
||||||
@@ -22,6 +18,13 @@ import EditUser from '../pages/users/EditUser'
|
|||||||
import ArchivedUserList from '../pages/users/ArchivedUserList'
|
import ArchivedUserList from '../pages/users/ArchivedUserList'
|
||||||
import ArchivedGroupList from '../pages/user_groups/ArchivedGroupList'
|
import ArchivedGroupList from '../pages/user_groups/ArchivedGroupList'
|
||||||
|
|
||||||
|
import AddAsset from "../pages/assets/AddAsset";
|
||||||
|
import ArchivedAssets from "../pages/assets/ArchivedAssets";
|
||||||
|
import ViewImageAsset from "../pages/assets/ViewImageAsset";
|
||||||
|
import ViewVideoAsset from "../pages/assets/ViewVideoAsset";
|
||||||
|
import ViewDocumentAsset from "../pages/assets/ViewDocumentAsset";
|
||||||
|
import AssetList from '../pages/assets/AssetList'
|
||||||
|
import EditAsset from '../pages/assets/EditAsset'
|
||||||
|
|
||||||
export const AdminRoutes = {
|
export const AdminRoutes = {
|
||||||
element: <ProtectedRoute allowedRoles={['admin']} />,
|
element: <ProtectedRoute allowedRoles={['admin']} />,
|
||||||
@@ -34,16 +37,9 @@ export const AdminRoutes = {
|
|||||||
{ index: true, element: <AdminDashboard /> }, // /admin
|
{ index: true, element: <AdminDashboard /> }, // /admin
|
||||||
{ path: 'my-profile', element: <ProfilePage /> },
|
{ path: 'my-profile', element: <ProfilePage /> },
|
||||||
|
|
||||||
// Users Management
|
// Users
|
||||||
{
|
{
|
||||||
path: 'users',
|
path: 'users',
|
||||||
element: <UserManagementLayout />,
|
|
||||||
children: [
|
|
||||||
{ index: true, element: <UsersDashboard /> },
|
|
||||||
|
|
||||||
// User View
|
|
||||||
{
|
|
||||||
path: 'all',
|
|
||||||
element: <Outlet />,
|
element: <Outlet />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <UserList /> },
|
{ index: true, element: <UserList /> },
|
||||||
@@ -54,7 +50,7 @@ export const AdminRoutes = {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
// User Group View
|
// User Groups
|
||||||
{
|
{
|
||||||
path: 'groups',
|
path: 'groups',
|
||||||
element: <Outlet />,
|
element: <Outlet />,
|
||||||
@@ -64,9 +60,21 @@ export const AdminRoutes = {
|
|||||||
{ path: 'archived', element: <ArchivedGroupList /> }
|
{ path: 'archived', element: <ArchivedGroupList /> }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
]
|
|
||||||
},
|
|
||||||
|
|
||||||
|
// Content Management
|
||||||
|
{
|
||||||
|
path: 'assets',
|
||||||
|
element: <Outlet />,
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <AssetList /> },
|
||||||
|
{ path: 'add', element: <AddAsset /> },
|
||||||
|
{ path: 'archived', element: <ArchivedAssets /> },
|
||||||
|
{ path: 'view/image/:assetId', element: <ViewImageAsset /> },
|
||||||
|
{ path: 'view/video/:assetId', element: <ViewVideoAsset /> },
|
||||||
|
{ path: 'view/document/:assetId', element: <ViewDocumentAsset /> },
|
||||||
|
{ path: 'edit/:assetId', element: <EditAsset /> },
|
||||||
|
],
|
||||||
|
}
|
||||||
// Add here
|
// Add here
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,15 +12,40 @@ export const pageSizes = [10, 25, 50, 75, 100, 250, 500, 750, 1000]
|
|||||||
|
|
||||||
// ── Badge style map for enum values ───────────────────────────────────────────
|
// ── Badge style map for enum values ───────────────────────────────────────────
|
||||||
export const BADGE_STYLES = {
|
export const BADGE_STYLES = {
|
||||||
|
// ── User status ───────────────────────────────
|
||||||
"Active": "bg-emerald-100 text-emerald-800 border-emerald-200",
|
"Active": "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||||
"Not Active": "bg-rose-100 text-rose-700 border-rose-200",
|
"Not Active": "bg-rose-100 text-rose-700 border-rose-200",
|
||||||
"Verified": "bg-blue-100 text-blue-800 border-blue-200",
|
"Verified": "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
"Not Verified": "bg-amber-100 text-amber-700 border-amber-200",
|
"Not Verified": "bg-amber-100 text-amber-700 border-amber-200",
|
||||||
|
|
||||||
|
// ── Registration / account type ───────────────
|
||||||
"system": "bg-violet-50 text-violet-700 border-violet-200",
|
"system": "bg-violet-50 text-violet-700 border-violet-200",
|
||||||
"google": "bg-orange-50 text-orange-700 border-orange-200",
|
"google": "bg-orange-50 text-orange-700 border-orange-200",
|
||||||
"admin": "bg-red-100 text-red-700 border-red-200",
|
"admin": "bg-red-100 text-red-700 border-red-200",
|
||||||
"user": "bg-blue-100 text-blue-700 border-blue-200",
|
"user": "bg-blue-100 text-blue-700 border-blue-200",
|
||||||
"staff": "bg-green-100 text-green-700 border-green-200",
|
"staff": "bg-green-100 text-green-700 border-green-200",
|
||||||
|
|
||||||
|
// ── Asset — file_type ─────────────────────────
|
||||||
|
"image": "bg-sky-100 text-sky-700 border-sky-200",
|
||||||
|
"video": "bg-purple-100 text-purple-700 border-purple-200",
|
||||||
|
"document": "bg-amber-100 text-amber-700 border-amber-200",
|
||||||
|
"other": "bg-zinc-100 text-zinc-600 border-zinc-200",
|
||||||
|
|
||||||
|
// ── Asset — storage_provider ──────────────────
|
||||||
|
"chibisafe": "bg-pink-100 text-pink-700 border-pink-200",
|
||||||
|
"local": "bg-zinc-100 text-zinc-600 border-zinc-200",
|
||||||
|
"s3": "bg-orange-100 text-orange-700 border-orange-200",
|
||||||
|
"gcs": "bg-blue-50 text-blue-600 border-blue-200",
|
||||||
|
"cloudinary": "bg-indigo-100 text-indigo-700 border-indigo-200",
|
||||||
|
|
||||||
|
// ── Asset — access_level ──────────────────────
|
||||||
|
"public": "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||||
|
"private": "bg-rose-100 text-rose-700 border-rose-200",
|
||||||
|
"restricted": "bg-amber-100 text-amber-700 border-amber-200",
|
||||||
|
|
||||||
|
// ── Asset — is_public ─────────────────────────
|
||||||
|
"true": "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||||
|
"false": "bg-zinc-100 text-zinc-600 border-zinc-200",
|
||||||
};
|
};
|
||||||
|
|
||||||
function EnumBadge({ value }) {
|
function EnumBadge({ value }) {
|
||||||
@@ -52,6 +77,8 @@ function renderCell(attr, value) {
|
|||||||
return <EnumBadge value={value ? "Active" : "Not Active"} />;
|
return <EnumBadge value={value ? "Active" : "Not Active"} />;
|
||||||
else if (attr.field === "is_verified")
|
else if (attr.field === "is_verified")
|
||||||
return <EnumBadge value={value ? "Verified" : "Not Verified"} />;
|
return <EnumBadge value={value ? "Verified" : "Not Verified"} />;
|
||||||
|
else if (attr.field === "is_public")
|
||||||
|
return <EnumBadge value={value ? "Public" : "Private"} />;
|
||||||
else
|
else
|
||||||
return <EnumBadge value={value} />;
|
return <EnumBadge value={value} />;
|
||||||
case "date": return <span className="text-xs text-muted-foreground">{formatDate(value)}</span>;
|
case "date": return <span className="text-xs text-muted-foreground">{formatDate(value)}</span>;
|
||||||
|
|||||||
Reference in New Issue
Block a user