pushy

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-28 11:30:01 +08:00
parent b03b204861
commit bac7168b1e
100 changed files with 5958 additions and 1976 deletions
+190 -55
View File
@@ -1,25 +1,32 @@
import { useEffect, useState, useCallback } from "react";
import { Search, CheckCircle2 } from "lucide-react";
import { useEffect, useState, useCallback, useRef } from "react";
import { Search, CheckCircle2, SlidersHorizontal, X } from "lucide-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, } from "@/components/ui/sheet";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAssets } from "@/contexts/AdminAssetsContext";
import api from "@/utils/api.util";
function EmptyState({ fileType }) {
return (
<div className="flex flex-col items-center justify-center h-48 gap-2">
<p className="text-sm text-muted-foreground">
No {fileType} assets found.
</p>
</div>
);
}
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
const DEBOUNCE_MS = 400;
function AssetCard({ asset, selected, onSelect }) {
const thumb = asset.thumbnail_url ?? asset.file_url;
const EXT_OPTIONS = {
image: ["svg", "png", "jpg", "jpeg", "webp", "gif"],
video: ["mp4", "mov", "webm", "avi"],
audio: ["mp3", "wav", "ogg", "m4a"],
document: ["pdf", "docx", "xlsx", "pptx"],
};
// ─── Asset Card ───────────────────────────────────────────────────────────────
// streamSrc is resolved at the sheet level (batch token request) — no per-card fetch.
function AssetCard({ asset, streamSrc, selected, onSelect }) {
const directThumb = asset.thumbnail_url ?? asset.file_url;
const thumb = streamSrc ?? directThumb;
return (
<button
@@ -28,18 +35,12 @@ function AssetCard({ asset, selected, onSelect }) {
className={[
"relative rounded-lg border-2 overflow-hidden transition-all text-left w-full",
"hover:border-primary/60 hover:shadow-sm",
selected
? "border-primary ring-2 ring-primary/20"
: "border-border",
selected ? "border-primary ring-2 ring-primary/20" : "border-border",
].join(" ")}
>
<div className="aspect-video bg-muted w-full overflow-hidden">
{thumb ? (
<img
src={thumb}
alt={asset.display_name}
className="w-full h-full object-cover"
/>
<img src={thumb} alt={asset.display_name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<span className="text-xs text-muted-foreground">No preview</span>
@@ -48,6 +49,9 @@ function AssetCard({ asset, selected, onSelect }) {
</div>
<div className="p-2">
<p className="text-xs font-medium truncate">{asset.display_name}</p>
{asset.extension && (
<p className="text-[10px] text-muted-foreground uppercase mt-0.5">{asset.extension}</p>
)}
</div>
{selected && (
<div className="absolute top-1.5 right-1.5">
@@ -58,45 +62,119 @@ function AssetCard({ asset, selected, onSelect }) {
);
}
function EmptyState({ fileType }) {
return (
<div className="flex flex-col items-center justify-center h-48 gap-2">
<p className="text-sm text-muted-foreground">No {fileType} assets found.</p>
</div>
);
}
// ─── Main Sheet ───────────────────────────────────────────────────────────────
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
if (!open) return null;
const { fetchAssets, assets, pagination, loading } = useAssets();
const [search, setSearch] = useState("");
const [committed, setCommitted] = useState(""); // ← only updates on search trigger
const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null);
const [search, setSearch] = useState("");
const [activeExts, setActiveExts] = useState(new Set());
const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null);
const [filterOpen, setFilterOpen] = useState(false);
// { [asset_id]: streamUrl } — resolved once per asset list via batch token request
const [streamUrls, setStreamUrls] = useState({});
const debounceRef = useRef(null);
const LIMIT = 12;
const triggerSearch = useCallback(() => {
setCommitted(search);
setPage(1);
}, [search]);
const extOptions = EXT_OPTIONS[fileType] ?? [];
// ── Fetch only when committed search, page, or open changes ──────────────
// ── Build and fire fetch ──────────────────────────────────────────────────
const doFetch = useCallback((searchVal, extSet, pg) => {
const filters = [
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(searchVal.trim() ? [{ id: "display_name", value: [searchVal.trim()] }] : []),
...(extSet.size > 0 ? [{ id: "extension", value: [...extSet] }] : []),
];
fetchAssets({ page: pg, limit: LIMIT, filters });
}, [fileType, fetchAssets]);
// ── Auto-search: debounce on search input change ──────────────────────────
useEffect(() => {
if (!open) return;
fetchAssets({
page,
limit: LIMIT,
filters: [
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(committed ? [{ id: "display_name", value: [committed] }] : []),
],
});
}, [open, page, committed, fileType]);
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
doFetch(search, activeExts, 1);
}, DEBOUNCE_MS);
return () => clearTimeout(debounceRef.current);
}, [search, open]);
// ── Immediate fetch on ext filter or page change ──────────────────────────
useEffect(() => {
if (!open) return;
doFetch(search, activeExts, page);
}, [activeExts, page, open]);
// ── Batch token fetch after assets load ───────────────────────────────────
// One request for all S3 assets on the current page instead of N per-card requests.
// This eliminates the thundering-herd / auth-refresh race that caused some cards to
// silently show "No preview" after a page reload (multiple 401s queuing simultaneously
// while the interceptor refreshes, some dropping if cancelled mid-flight).
useEffect(() => {
if (!assets.length) return;
const s3Ids = assets
.filter((a) => a.storage_provider === "s3" && !a.thumbnail_url && !a.file_url)
.map((a) => a.asset_id);
if (!s3Ids.length) return;
let cancelled = false;
api.post("/admin/media/tokens", { asset_ids: s3Ids })
.then(({ data }) => {
if (cancelled) return;
const tokens = data.data?.tokens ?? {};
const urls = {};
for (const [id, token] of Object.entries(tokens)) {
urls[id] = `${STREAM_BASE}/${token}`;
}
setStreamUrls((prev) => ({ ...prev, ...urls }));
})
.catch((err) => {
console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
});
return () => { cancelled = true; };
}, [assets]);
// ── Reset on close ────────────────────────────────────────────────────────
useEffect(() => {
if (!open) {
setSearch("");
setCommitted("");
setActiveExts(new Set());
setPage(1);
setSelected(null);
setFilterOpen(false);
setStreamUrls({});
}
}, [open]);
const toggleExt = (ext) => {
setActiveExts((prev) => {
const next = new Set(prev);
next.has(ext) ? next.delete(ext) : next.add(ext);
return next;
});
setPage(1);
};
const clearFilters = () => {
setActiveExts(new Set());
setPage(1);
};
const handleSelect = (asset) => {
setSelected(asset.asset_id);
onSelect(asset);
@@ -107,6 +185,8 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
: "Assets";
const hasActiveFilters = activeExts.size > 0;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
@@ -114,13 +194,11 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
{/* ── Header ── */}
<SheetHeader className="px-6 pt-6 pb-4 border-b">
<SheetTitle>Select {label}</SheetTitle>
<SheetDescription>
Click an asset to attach it.
</SheetDescription>
<SheetDescription>Click an asset to attach it.</SheetDescription>
</SheetHeader>
{/* ── Search ── */}
<div className="px-6 py-3 border-b">
{/* ── Search + Filter ── */}
<div className="px-6 py-3 border-b space-y-2">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -128,18 +206,74 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
placeholder={`Search ${label.toLowerCase()}…`}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && triggerSearch()}
className="pl-9"
/>
</div>
<Button
type="button"
onClick={triggerSearch}
disabled={loading}
>
{loading ? <Spinner className="h-4 w-4" /> : "Search"}
</Button>
{extOptions.length > 0 && (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant={hasActiveFilters ? "default" : "outline"}
size="icon"
className="relative shrink-0"
>
<SlidersHorizontal className="h-4 w-4" />
{hasActiveFilters && (
<span className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground font-medium">
{activeExts.size}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-3 space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">File type</p>
{hasActiveFilters && (
<button
type="button"
onClick={clearFilters}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{extOptions.map((ext) => (
<button
key={ext}
type="button"
onClick={() => toggleExt(ext)}
className={[
"px-2.5 py-1 rounded-full border text-xs uppercase font-mono transition-colors",
activeExts.has(ext)
? "bg-primary text-primary-foreground border-primary"
: "bg-card border-border hover:bg-muted",
].join(" ")}
>
{ext}
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
{hasActiveFilters && (
<div className="flex flex-wrap gap-1.5">
{[...activeExts].map((ext) => (
<Badge key={ext} variant="secondary" className="gap-1 pr-1 uppercase text-[10px] font-mono">
{ext}
<button type="button" onClick={() => toggleExt(ext)} className="ml-0.5 hover:opacity-70">
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* ── Grid ── */}
@@ -156,6 +290,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
<AssetCard
key={asset.asset_id}
asset={asset}
streamSrc={streamUrls[String(asset.asset_id)] ?? null}
selected={selected === asset.asset_id}
onSelect={handleSelect}
/>
@@ -194,4 +329,4 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
</SheetContent>
</Sheet>
);
}
}
@@ -7,6 +7,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { cn } from "@/lib/utils";
import { useDateFormat } from "@/hooks/useDateFormat";
const TYPE_ICON = {
achievement: Trophy,
@@ -31,20 +32,10 @@ function timeAgo(dateStr) {
return `${Math.floor(h / 24)}d ago`;
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
export default function ClientNotificationBell() {
const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } =
useClientNotifications();
const { fmtDateTime } = useDateFormat();
const [selected, setSelected] = useState(null);
const [copiedCode, setCopiedCode] = useState(false);
@@ -79,15 +70,16 @@ export default function ClientNotificationBell() {
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center justify-between px-4 pt-3">
<span className="text-sm font-semibold">Notifications</span>
{unseenCount > 0 && (
<button
<Button
onClick={markAllSeen}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
variant="ghost"
size="sm"
>
Mark all as read
</button>
</Button>
)}
</div>
@@ -186,7 +178,7 @@ export default function ClientNotificationBell() {
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="capitalize">{selected?.type}</span>
<span>{selected ? formatDate(selected.createdAt) : ""}</span>
<span>{selected ? fmtDateTime(selected.createdAt) : ""}</span>
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,230 @@
// ─── components/Dialogs/BanUserDialog.jsx ─────────────────────────────────────
import { useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { CalendarIcon, Clock2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { fmtDate } from "@/utils/datetime.util";
const EMPTY = { reason: "", ban_type: "temporary", expires_at: "", _date: null, _time: "12:00" };
/**
* Ban dialog — single or bulk.
*
* Single: <BanUserDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <BanUserDialog ids={[1, 2, 3]} entityLabel="User" ... />
*
* @param {Function} onBan (payload) => Promise — called with { reason, ban_type, expires_at? }
* For bulk, caller merges ids on top.
*/
export function BanUserDialog({
open,
onOpenChange,
entity,
ids,
entityLabel = "User",
getName,
onBan,
loading,
onSuccess,
}) {
const [form, setForm] = useState(EMPTY);
const [errors, setErrors] = useState({});
const [calOpen, setCalOpen] = useState(false);
const mergeDateTime = (date, time) => {
if (!date) return "";
const [h, m] = (time || "12:00").split(":").map(Number);
const d = new Date(date);
d.setHours(h, m, 0, 0);
return d.toISOString();
};
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const displayName = isBulk
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
: (getName?.(entity) ?? entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user");
const validate = () => {
const e = {};
if (!form.reason.trim()) e.reason = "Reason is required.";
if (form.ban_type === "temporary") {
if (!form.expires_at) e.expires_at = "Expiry date is required.";
else if (new Date(form.expires_at) <= new Date()) e.expires_at = "Expiry must be in the future.";
}
setErrors(e);
return Object.keys(e).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
const payload = {
reason: form.reason.trim(),
ban_type: form.ban_type,
...(form.ban_type === "temporary" ? { expires_at: form.expires_at } : {}),
};
const res = await onBan(payload);
if (res) {
setForm(EMPTY);
setErrors({});
onOpenChange(false);
onSuccess?.();
}
};
const handleOpenChange = (v) => {
if (!v) { setForm(EMPTY); setErrors({}); }
onOpenChange(v);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-destructive">
Ban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</DialogTitle>
<DialogDescription>
You are about to ban{" "}
<span className="font-medium text-foreground">{displayName}</span>.
They will be logged out immediately and blocked from accessing the platform.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
{/* Reason */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="ban-reason">
Reason <span className="text-destructive">*</span>
</Label>
<Textarea
id="ban-reason"
placeholder="Explain why this user is being banned…"
rows={3}
value={form.reason}
onChange={(e) => setForm((f) => ({ ...f, reason: e.target.value }))}
aria-invalid={!!errors.reason}
/>
{errors.reason && (
<p className="text-xs text-destructive">{errors.reason}</p>
)}
</div>
{/* Ban type */}
<div className="flex flex-col gap-2">
<Label>Ban Duration</Label>
<RadioGroup
value={form.ban_type}
onValueChange={(v) => setForm((f) => ({ ...f, ban_type: v, expires_at: "" }))}
className="flex gap-6"
>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<RadioGroupItem value="permanent" id="ban-perm" />
<span>Permanent</span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<RadioGroupItem value="temporary" id="ban-temp" />
<span>Temporary</span>
</label>
</RadioGroup>
</div>
{/* Expiry — shown only for temporary */}
{form.ban_type === "temporary" && (
<div className="flex flex-col gap-1.5">
<Label>
Ban Until <span className="text-destructive">*</span>
</Label>
<Popover open={calOpen} onOpenChange={setCalOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-invalid={!!errors.expires_at}
className={cn(
"w-full justify-start text-left font-normal",
!form._date && "text-muted-foreground",
errors.expires_at && "border-destructive"
)}
>
<CalendarIcon className="mr-2 size-4 shrink-0" />
{form._date ? fmtDate(form._date) : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Card size="sm" className="w-fit rounded-none border-0 shadow-none ring-0">
<CardContent>
<Calendar
mode="single"
selected={form._date ?? undefined}
onSelect={(date) => {
const merged = mergeDateTime(date, form._time);
setForm((f) => ({ ...f, _date: date ?? null, expires_at: merged }));
}}
disabled={(d) => d < new Date(Date.now() + 60_000)}
className="p-0"
initialFocus
/>
</CardContent>
<CardFooter className="border-t bg-card">
<FieldGroup>
<Field>
<FieldLabel htmlFor="ban-expires-time">Time</FieldLabel>
<InputGroup>
<InputGroupInput
id="ban-expires-time"
type="time"
step="60"
value={form._time}
onChange={(e) => {
const merged = mergeDateTime(form._date, e.target.value);
setForm((f) => ({ ...f, _time: e.target.value, expires_at: merged }));
}}
className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
<InputGroupAddon align="inline-end">
<Clock2Icon className="text-muted-foreground" />
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
</CardFooter>
</Card>
</PopoverContent>
</Popover>
{errors.expires_at && (
<p className="text-xs text-destructive">{errors.expires_at}</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleSubmit}
disabled={loading}
>
{loading && <Spinner className="size-4 mr-2" />}
Ban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,93 @@
// ─── components/Dialogs/UnbanDialog.jsx ───────────────────────────────────────
import { useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
/**
* Unban dialog — single or bulk.
*
* Single: <UnbanDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <UnbanDialog ids={[1, 2, 3]} entityLabel="User" ... />
*
* @param {Function} onUnban (payload) => Promise — { lift_reason? }
*/
export function UnbanDialog({
open,
onOpenChange,
entity,
ids,
entityLabel = "User",
getName,
onUnban,
loading,
onSuccess,
}) {
const [liftReason, setLiftReason] = useState("");
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const displayName = isBulk
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
: (getName?.(entity) ?? entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user");
const handleSubmit = async () => {
const res = await onUnban({ lift_reason: liftReason.trim() || undefined });
if (res) {
setLiftReason("");
onOpenChange(false);
onSuccess?.();
}
};
const handleOpenChange = (v) => {
if (!v) setLiftReason("");
onOpenChange(v);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Unban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}</DialogTitle>
<DialogDescription>
Remove the ban on{" "}
<span className="font-medium text-foreground">{displayName}</span>.
They will regain access to the platform immediately.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5 py-2">
<Label htmlFor="lift-reason">
Lift Reason <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Textarea
id="lift-reason"
placeholder="Reason for lifting this ban…"
rows={3}
value={liftReason}
onChange={(e) => setLiftReason(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button
className="bg-emerald-600 text-white hover:bg-emerald-700"
onClick={handleSubmit}
disabled={loading}
>
{loading && <Spinner className="size-4 mr-2" />}
Unban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+3 -12
View File
@@ -7,6 +7,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
import { cn } from "@/lib/utils";
import { useDateFormat } from "@/hooks/useDateFormat";
const TYPE_ICON = {
task_overdue: AlertCircle,
@@ -29,20 +30,10 @@ function timeAgo(dateStr) {
return `${Math.floor(h / 24)}d ago`;
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
export default function NotificationBell() {
const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } =
useAdminNotifications();
const { fmtDateTime } = useDateFormat();
const [selected, setSelected] = useState(null);
@@ -147,7 +138,7 @@ export default function NotificationBell() {
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="capitalize">{selected?.type?.replace(/_/g, ' ')}</span>
<span>{selected ? formatDate(selected.createdAt) : ""}</span>
<span>{selected ? fmtDateTime(selected.createdAt) : ""}</span>
</div>
</DialogContent>
</Dialog>
+42 -15
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { AlertTriangle } from "lucide-react";
/**
* Generic sheet for selecting and adding users to any entity
@@ -25,6 +26,8 @@ import { Spinner } from "@/components/ui/spinner";
* @param {string} [props.idKey] Key for the user id. Default: "user_id"
* @param {string} [props.labelKey] Key for the display name. Default: "full_name"
* @param {string} [props.subLabelKey] Optional secondary line (e.g. "email")
* @param {string} [props.warningKey] If set, users with a truthy value at this key show
* a "will be moved" warning (e.g. "current_group")
*
* @param {Function} props.onSubmit Called with selected ids[]
*
@@ -63,6 +66,7 @@ export function AddSheet({
idKey = "user_id",
labelKey = "full_name",
subLabelKey = null,
warningKey = null,
onSubmit,
}) {
@@ -102,6 +106,14 @@ export function AddSheet({
const allFilteredSelected =
filtered.length > 0 && filtered.every((u) => selected.includes(u[idKey]));
const movingCount = useMemo(() => {
if (!warningKey) return 0;
return selected.filter((id) => {
const user = users.find((u) => u[idKey] === id);
return !!user?.[warningKey];
}).length;
}, [selected, users, warningKey, idKey]);
async function handleSubmit() {
if (!selected.length) return;
await onSubmit(selected);
@@ -166,9 +178,10 @@ export function AddSheet({
</p>
) : (
filtered.map((user) => {
const id = user[idKey];
const label = user[labelKey];
const sub = subLabelKey ? user[subLabelKey] : null;
const id = user[idKey];
const label = user[labelKey];
const sub = subLabelKey ? user[subLabelKey] : null;
const warning = warningKey ? user[warningKey] : null;
return (
<label
@@ -180,13 +193,19 @@ export function AddSheet({
checked={selected.includes(id)}
onChange={() => toggle(id)}
/>
<div className="flex flex-col min-w-0">
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate">{label}</span>
{sub && (
<span className="text-xs text-muted-foreground truncate">
{sub}
</span>
)}
{warning && (
<span className="text-xs text-amber-600 flex items-center gap-1 mt-0.5">
<AlertTriangle className="size-3 shrink-0" />
Also in: {warning}
</span>
)}
</div>
</label>
);
@@ -197,17 +216,25 @@ export function AddSheet({
</div>
{/* Footer */}
<div className="shrink-0 flex gap-2 border-t px-6 py-4">
<Button variant="outline" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button
className="flex-1"
disabled={!selected.length || loading}
onClick={handleSubmit}
>
{selected.length > 0 ? `${submitLabel} (${selected.length})` : submitLabel}
</Button>
<div className="shrink-0 flex flex-col gap-2 border-t px-6 py-4">
{movingCount > 0 && (
<p className="text-xs text-amber-600 flex items-center gap-1.5">
<AlertTriangle className="size-3 shrink-0" />
{movingCount} user{movingCount > 1 ? 's' : ''} already belong{movingCount === 1 ? 's' : ''} to other group{movingCount > 1 ? 's' : ''}.
</p>
)}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button
className="flex-1"
disabled={!selected.length || loading}
onClick={handleSubmit}
>
{selected.length > 0 ? `${submitLabel} (${selected.length})` : submitLabel}
</Button>
</div>
</div>
</SheetContent>
+5 -5
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from "react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -14,14 +15,12 @@ const FIELD_DISPLAY_MAP = {
const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP);
// ─── Generic formatter ────────────────────────────────────────────────────────
const formatFilterItem = (item, field, type) => {
const formatFilterItem = (item, field, type, fmtDate) => {
if (FIELD_DISPLAY_MAP[field]) {
return FIELD_DISPLAY_MAP[field][String(item)] ?? item;
}
if (type === "date" && item) {
return new Date(item).toLocaleDateString("en-US", {
year: "numeric", month: "long", day: "numeric",
});
return fmtDate(item);
}
return item;
};
@@ -35,6 +34,7 @@ const EmptyState = ({ search }) => (
// ─── Reusable item list renderer ──────────────────────────────────────────────
const FilterList = ({ items, field, type, selected, onToggle, inputType = "checkbox" }) => {
const { fmtDate } = useDateFormat();
if (items.length === 0) return <EmptyState />;
return items.map((item) => (
@@ -45,7 +45,7 @@ const FilterList = ({ items, field, type, selected, onToggle, inputType = "check
checked={selected.includes(String(item))}
onChange={() => onToggle(item)}
/>
{formatFilterItem(item, field, type)}
{formatFilterItem(item, field, type, fmtDate)}
</label>
));
};
+63 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { useTheme } from '@/contexts/ThemeContext'
import { useProfile } from '@/contexts/ProfileProvider'
import { useDateTimePreference } from '@/contexts/DateTimePreferenceContext'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Label } from '@/components/ui/label'
@@ -11,7 +12,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem,
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogContent, } from '@/components/ui/alert-dialog'
import { User, Settings, LogOut, Sun, Moon, Monitor, Loader2, Check } from 'lucide-react'
import { User, Settings, LogOut, Sun, Moon, Monitor, Loader2, Check, Clock } from 'lucide-react'
import { AVATAR_COLORS } from '@/data/profile.data'
@@ -39,13 +40,35 @@ function ThemeOption({ value, label, icon: Icon, active, onClick }) {
)
}
// ─── Timezone Option ──────────────────────────────────────────────────────────
function TimezoneOption({ value, label, description, active, onClick }) {
return (
<button
onClick={() => onClick(value)}
className={`flex flex-col gap-1 p-3 rounded-lg border-2 transition-all cursor-pointer w-full text-left
${active
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted/40'
}`}
>
<span className={`text-sm font-semibold ${active ? 'text-primary' : 'text-foreground'}`}>
{label}
</span>
<span className="text-xs text-muted-foreground">{description}</span>
{active && <Check size={12} className="text-primary mt-0.5" />}
</button>
)
}
// ─── Settings Dialog ──────────────────────────────────────────────────────────
function SettingsDialog({ open, onClose }) {
const { theme, setTheme } = useTheme()
const { timezone, setTimezone } = useDateTimePreference()
const [tab, setTab] = useState('appearance')
const TABS = [
{ id: 'appearance', label: 'Appearance', icon: Sun },
{ id: 'datetime', label: 'Date & Time', icon: Clock },
]
return (
@@ -100,6 +123,45 @@ function SettingsDialog({ open, onClose }) {
</div>
)}
{/* Date & Time */}
{tab === 'datetime' && (
<div className="space-y-5">
<div>
<h2 className="text-base font-semibold">Date &amp; Time</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Choose how dates and times are displayed across the platform.
Your locale format is taken from your browser&apos;s language setting.
</p>
</div>
<Separator />
<div>
<Label className="text-sm font-medium mb-3 block">Timezone Display</Label>
<div className="grid grid-cols-2 gap-3">
<TimezoneOption
value="local"
label="Local"
description="Dates shown in your device's local timezone."
active={timezone === 'local'}
onClick={setTimezone}
/>
<TimezoneOption
value="UTC"
label="UTC"
description="Dates shown in Coordinated Universal Time (UTC+0)."
active={timezone === 'UTC'}
onClick={setTimezone}
/>
</div>
</div>
<div className="rounded-lg border bg-muted/40 p-3 text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Preview</p>
<p>Date: <span className="text-foreground">{new Date().toLocaleDateString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}</span></p>
<p>Date &amp; Time: <span className="text-foreground">{new Date().toLocaleString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}</span></p>
{timezone === 'UTC' && <p className="text-amber-600 dark:text-amber-400 font-medium">All times shown in UTC</p>}
</div>
</div>
)}
</div>
</div>
</DialogContent>