mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
};
|
||||
|
||||
@@ -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 & 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'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 & 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>
|
||||
|
||||
Reference in New Issue
Block a user