diff --git a/src/App.jsx b/src/App.jsx index d921180..2a5639f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext'; import { ThemeProvider } from './contexts/ThemeContext'; +import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext'; import { Helmet, HelmetProvider } from "react-helmet-async"; import { TooltipProvider } from './components/ui/tooltip'; import { setAuthInterceptor } from './utils/api.util'; @@ -78,11 +79,13 @@ export default function App() { - - - - - + + + + + + + ); diff --git a/src/components/generic/AssetPickerSheet.jsx b/src/components/generic/AssetPickerSheet.jsx index 52442b8..b12f994 100644 --- a/src/components/generic/AssetPickerSheet.jsx +++ b/src/components/generic/AssetPickerSheet.jsx @@ -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 ( -
-

- No {fileType} assets found. -

-
- ); -} +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 ( + + {extOptions.length > 0 && ( + + + + + +
+

File type

+ {hasActiveFilters && ( + + )} +
+
+ {extOptions.map((ext) => ( + + ))} +
+
+
+ )} + + {hasActiveFilters && ( +
+ {[...activeExts].map((ext) => ( + + {ext} + + + ))} +
+ )} {/* ── Grid ── */} @@ -156,6 +290,7 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { @@ -194,4 +329,4 @@ export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) { ); -} \ No newline at end of file +} diff --git a/src/components/generic/ClientNotificationBell.jsx b/src/components/generic/ClientNotificationBell.jsx index 63e9048..777ecfa 100644 --- a/src/components/generic/ClientNotificationBell.jsx +++ b/src/components/generic/ClientNotificationBell.jsx @@ -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() { -
+
Notifications {unseenCount > 0 && ( - + )}
@@ -186,7 +178,7 @@ export default function ClientNotificationBell() {
{selected?.type} - {selected ? formatDate(selected.createdAt) : ""} + {selected ? fmtDateTime(selected.createdAt) : ""}
diff --git a/src/components/generic/Dialogs/BanUserDialog.jsx b/src/components/generic/Dialogs/BanUserDialog.jsx new file mode 100644 index 0000000..46fadef --- /dev/null +++ b/src/components/generic/Dialogs/BanUserDialog.jsx @@ -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: r.name} ... /> + * Bulk: + * + * @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 ( + + + + + Ban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel} + + + You are about to ban{" "} + {displayName}. + They will be logged out immediately and blocked from accessing the platform. + + + +
+ {/* Reason */} +
+ +