mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
+54
-13
@@ -1,14 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
|
||||
import { Helmet, HelmetProvider } from "react-helmet-async";
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { setAuthInterceptor } from './utils/api.util';
|
||||
import { attachCsrfInterceptor, fetchCsrfToken } from './utils/csrf.util';
|
||||
|
||||
import AppRouter from './routes/AppRouter';
|
||||
|
||||
import './index.css';
|
||||
import 'react-photo-view/dist/react-photo-view.css';
|
||||
|
||||
function AppWithAuth() {
|
||||
const { accessTokenRef, setAccessToken, setUser, restoreSession, logout } = useAuth()
|
||||
@@ -16,33 +15,75 @@ function AppWithAuth() {
|
||||
useEffect(() => {
|
||||
attachCsrfInterceptor()
|
||||
fetchCsrfToken()
|
||||
|
||||
setAuthInterceptor(
|
||||
() => accessTokenRef.current, // ← always fresh token
|
||||
() => accessTokenRef.current,
|
||||
(newToken) => {
|
||||
if (newToken) {
|
||||
setAccessToken(newToken)
|
||||
setUser(decodeToken(newToken))
|
||||
// setUser(decodeToken(newToken))
|
||||
/*****************************************************************************
|
||||
* REMOVED: setUser(decodeToken(newToken))
|
||||
*
|
||||
* WHY: This callback fires on EVERY silent token refresh (401 → /auth/refresh
|
||||
* → retry), not just on initial login. decodeToken() only returns the raw JWT
|
||||
* payload — { user_id, email, acc_type, reg_type, iat, exp } — which does NOT
|
||||
* include personal_info, achievements, or any profile data.
|
||||
*
|
||||
* Each silent refresh was overwriting the full user object (originally set by
|
||||
* login/verifyOTP/restoreSession via safeUser()) with this stripped-down JWT
|
||||
* payload. After the first refresh, personal_info became undefined, causing:
|
||||
* - ClientNav to fall back to email instead of full name
|
||||
* - ROLE_CONFIG lookups to behave inconsistently
|
||||
* - Any component reading user.personal_info to silently break
|
||||
*
|
||||
* SUGGESTION: user state should ONLY be set from actual API responses that return
|
||||
* safeUser() (login, verifyOTP, restoreSession). Token refresh should update
|
||||
* ONLY the access token — never touch user. This applies uniformly across
|
||||
* admin, client, and staff roles since they all share this interceptor.
|
||||
*
|
||||
* Basis to see:
|
||||
* AuthContext.jsx the setUser state for login(), restoreSession()
|
||||
* auth.controller.js return outputs for login and refreshToken and so safeUser()
|
||||
*
|
||||
*
|
||||
*****************************************************************************/
|
||||
} else {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
restoreSession() // ← only here, never in route guards
|
||||
restoreSession()
|
||||
}, [])
|
||||
|
||||
return <AppRouter />
|
||||
}
|
||||
|
||||
{/* if there is revisions then remove Tooltip */ }
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<HelmetProvider>
|
||||
{/* Global fallback — overridden by any mounted PageMeta */}
|
||||
<Helmet>
|
||||
<title>STARR | Philproperties</title>
|
||||
<meta name="description" content="This is still in development phase. Come back soon." />
|
||||
<meta name="keywords" content="philproperties, online courses, sales training" />
|
||||
<meta property="og:title" content="STARR | Philproperties" />
|
||||
<meta property="og:description" content="This is still in development phase. Come back soon." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="STARR | Philproperties" />
|
||||
<meta name="twitter:description" content="This is still in development phase. Come back soon." />
|
||||
<meta name="twitter:image" content="https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC" />
|
||||
</Helmet>
|
||||
<ThemeProvider defaultTheme="light" storageKey="vite-ui-theme">
|
||||
<AuthProvider>
|
||||
<AppWithAuth />
|
||||
</AuthProvider>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<AuthProvider>
|
||||
<AppWithAuth />
|
||||
</AuthProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</HelmetProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Tabs({ className, ...props }) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
className={cn(
|
||||
"flex flex-col gap-2 data-[orientation=vertical]:flex-row",
|
||||
className,
|
||||
)}
|
||||
data-slot="tabs"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsList({ variant = "default", className, children, ...props }) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
className={cn(
|
||||
"relative z-0 flex w-fit items-center justify-center gap-x-0.5 text-muted-foreground",
|
||||
"data-[orientation=vertical]:flex-col",
|
||||
variant === "default"
|
||||
? "rounded-lg bg-muted p-0.5 text-muted-foreground/72"
|
||||
: "data-[orientation=vertical]:px-1 data-[orientation=horizontal]:py-1 *:data-[slot=tabs-tab]:hover:bg-accent",
|
||||
className,
|
||||
)}
|
||||
data-slot="tabs-list"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TabsPrimitive.Indicator
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) -translate-y-(--active-tab-bottom) transition-[width,translate] duration-200 ease-in-out",
|
||||
variant === "underline"
|
||||
? "z-10 bg-primary data-[orientation=horizontal]:h-0.5 data-[orientation=vertical]:w-0.5 data-[orientation=vertical]:-translate-x-px data-[orientation=horizontal]:translate-y-px"
|
||||
: "-z-1 rounded-md bg-background shadow-sm/5 dark:bg-input",
|
||||
)}
|
||||
data-slot="tab-indicator"
|
||||
/>
|
||||
</TabsPrimitive.List>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsTab({ className, ...props }) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
className={cn(
|
||||
"relative flex h-9 shrink-0 grow cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-[calc(--spacing(2.5)-1px)] font-medium text-base outline-none transition-[color,background-color,box-shadow] hover:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring data-disabled:pointer-events-none data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start data-active:text-foreground data-disabled:opacity-64 sm:h-8 sm:text-sm [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:-mx-0.5 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
data-slot="tabs-tab"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsPanel({ className, ...props }) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
data-slot="tabs-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { TabsPrimitive, TabsTab as TabsTrigger, TabsPanel as TabsContent };
|
||||
@@ -1,6 +1,6 @@
|
||||
// components/generic/CMS/AddBlockMenu.jsx
|
||||
|
||||
import { Plus, Type, Image, ImagePlay, Video, VideoIcon } from "lucide-react";
|
||||
import { Plus, Type, Image, ImagePlay, Video, VideoIcon, Music2, Code2, FileText } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -42,6 +42,24 @@ const BLOCK_TYPES = [
|
||||
description: "Text beside a video",
|
||||
icon: <Video className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
type: "audio",
|
||||
label: "Audio",
|
||||
description: "An audio player",
|
||||
icon: <Music2 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
type: "code",
|
||||
label: "Code",
|
||||
description: "A syntax-highlighted code block",
|
||||
icon: <Code2 className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
type: "markdown",
|
||||
label: "Markdown",
|
||||
description: "Rich text written in Markdown",
|
||||
icon: <FileText className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
export function AddBlockMenu({ onAdd }) {
|
||||
|
||||
@@ -59,6 +59,7 @@ function AssetCard({ asset, selected, onSelect }) {
|
||||
}
|
||||
|
||||
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
|
||||
if (!open) return null;
|
||||
const { fetchAssets, assets, pagination, loading } = useAssets();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import { Upload, Trash2, Loader2, ZoomIn, ZoomOut, ArrowLeft } from 'lucide-react'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const MAX_MB = 5
|
||||
const MAX_BYTES = MAX_MB * 1024 * 1024
|
||||
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
|
||||
function loadImage(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.addEventListener('load', () => resolve(img))
|
||||
img.addEventListener('error', reject)
|
||||
img.setAttribute('crossOrigin', 'anonymous')
|
||||
img.src = src
|
||||
})
|
||||
}
|
||||
|
||||
async function cropToBlob(imageSrc, pixels, outputSize = 512) {
|
||||
const img = await loadImage(imageSrc)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = outputSize
|
||||
canvas.height = outputSize
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(img, pixels.x, pixels.y, pixels.width, pixels.height, 0, 0, outputSize, outputSize)
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.93))
|
||||
}
|
||||
|
||||
export default function AvatarUploadDialog({
|
||||
open,
|
||||
onClose,
|
||||
currentAvatarUrl = '',
|
||||
initials = '?',
|
||||
onUpload,
|
||||
onDelete,
|
||||
loading = false,
|
||||
}) {
|
||||
const [step, setStep] = useState('pick') // 'pick' | 'crop'
|
||||
const [imageSrc, setImageSrc] = useState(null)
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 })
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null)
|
||||
|
||||
const inputRef = useRef(null)
|
||||
|
||||
const reset = () => {
|
||||
setStep('pick')
|
||||
setImageSrc(null)
|
||||
setFileName('')
|
||||
setError('')
|
||||
setDragOver(false)
|
||||
setCrop({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
setCroppedAreaPixels(null)
|
||||
}
|
||||
|
||||
const handleClose = () => { reset(); onClose() }
|
||||
|
||||
const validate = (f) => {
|
||||
if (!ALLOWED.includes(f.type)) {
|
||||
setError('Unsupported format. Use JPEG, PNG, WebP or GIF.')
|
||||
return false
|
||||
}
|
||||
if (f.size > MAX_BYTES) {
|
||||
setError(`File too large — max is ${MAX_MB} MB.`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const applyFile = (f) => {
|
||||
setError('')
|
||||
if (!validate(f)) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
setImageSrc(e.target.result)
|
||||
setFileName(f.name)
|
||||
setCrop({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
setStep('crop')
|
||||
}
|
||||
reader.readAsDataURL(f)
|
||||
}
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) applyFile(f)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const f = e.dataTransfer.files?.[0]
|
||||
if (f) applyFile(f)
|
||||
}
|
||||
|
||||
const onCropComplete = useCallback((_, pixels) => {
|
||||
setCroppedAreaPixels(pixels)
|
||||
}, [])
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!croppedAreaPixels) return
|
||||
const blob = await cropToBlob(imageSrc, croppedAreaPixels)
|
||||
const file = new File([blob], fileName || 'avatar.jpg', { type: 'image/jpeg' })
|
||||
const result = await onUpload(file)
|
||||
if (result?.success) handleClose()
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
const result = await onDelete()
|
||||
if (result?.success) handleClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md p-0 overflow-hidden gap-0">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<DialogHeader className="px-5 pt-5 pb-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
{step === 'crop' && (
|
||||
<button
|
||||
onClick={() => { setStep('pick'); setImageSrc(null) }}
|
||||
className="p-1 -ml-1 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
)}
|
||||
<DialogTitle className="text-base">
|
||||
{step === 'crop' ? 'Adjust photo' : 'Change Avatar'}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ── Step: pick ── */}
|
||||
{step === 'pick' && (
|
||||
<div className="px-5 py-5 space-y-4">
|
||||
<div className="flex justify-center pb-1">
|
||||
<Avatar className="size-24 ring-2 ring-border">
|
||||
<AvatarImage src={currentAvatarUrl} />
|
||||
<AvatarFallback className="text-2xl font-semibold">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-xl px-6 py-6 text-center cursor-pointer transition-colors select-none',
|
||||
dragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground/40 hover:bg-muted/40',
|
||||
error && !dragOver && 'border-destructive/60 bg-destructive/5'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={ALLOWED.join(',')}
|
||||
className="hidden"
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<div className={cn(
|
||||
'size-10 rounded-full flex items-center justify-center mx-auto mb-3',
|
||||
error ? 'bg-destructive/10' : 'bg-muted'
|
||||
)}>
|
||||
<Upload className={cn('size-5', error ? 'text-destructive/70' : 'text-muted-foreground')} />
|
||||
</div>
|
||||
<p className="text-sm font-medium">
|
||||
{dragOver ? 'Drop to select' : 'Click or drag & drop to upload'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
JPEG, PNG, WebP or GIF · max {MAX_MB} MB
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive font-medium mt-2">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: crop ── */}
|
||||
{step === 'crop' && (
|
||||
<div>
|
||||
{/* Cropper canvas */}
|
||||
<div className="relative h-72 bg-zinc-900">
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
style={{
|
||||
containerStyle: { borderRadius: 0 },
|
||||
cropAreaStyle: { border: '2px solid rgba(255,255,255,0.85)', boxShadow: '0 0 0 9999px rgba(0,0,0,0.55)' },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="px-5 py-4 space-y-2.5 border-b bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.max(1, +(z - 0.1).toFixed(2)))}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ZoomOut size={15} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 h-1.5 appearance-none rounded-full bg-border cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:size-4
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-foreground
|
||||
[&::-webkit-slider-thumb]:cursor-pointer
|
||||
[&::-webkit-slider-thumb]:shadow-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.min(3, +(z + 0.1).toFixed(2)))}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ZoomIn size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Drag to reposition · Scroll or pinch to zoom
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Footer ── */}
|
||||
{/* mx-0 mb-0 rounded-none bg-transparent override DialogFooter's negative margins
|
||||
that assume p-4 on DialogContent — we use p-0 so those would overshoot */}
|
||||
<DialogFooter className="mx-0 mb-0 rounded-none bg-transparent border-t px-5 py-4 sm:justify-end">
|
||||
{step === 'pick' && currentAvatarUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10 gap-1.5 sm:mr-auto"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading
|
||||
? <Loader2 className="size-3.5 animate-spin" />
|
||||
: <Trash2 className="size-3.5" />
|
||||
}
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" size="sm" disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
{step === 'crop' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleUpload}
|
||||
disabled={!croppedAreaPixels || loading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{loading && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Upload photo
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
// components/generic/CMS/BlockList.jsx
|
||||
|
||||
import { BlockWrapper } from "./BlockWrapper";
|
||||
import { TextBlock } from "./Blocks/TextBlock";
|
||||
import { ImageBlock } from "./Blocks/ImageBlock";
|
||||
import { TextImageBlock } from "./Blocks/TextImageBlock";
|
||||
import { VideoBlock } from "./Blocks/VideoBlock";
|
||||
import { TextVideoBlock } from "./Blocks/TextVideoBlock";
|
||||
import { BlockWrapper } from "./BlockWrapper";
|
||||
import { TextBlock } from "./Blocks/Admin/TextBlock";
|
||||
import { ImageBlock } from "./Blocks/Admin/ImageBlock";
|
||||
import { TextImageBlock } from "./Blocks/Admin/TextImageBlock";
|
||||
import { VideoBlock } from "./Blocks/Admin/VideoBlock";
|
||||
import { TextVideoBlock } from "./Blocks/Admin/TextVideoBlock";
|
||||
import { AudioBlock } from "./Blocks/Admin/AudioBlock";
|
||||
import { CodeBlock } from "./Blocks/Admin/CodeBlock";
|
||||
import { MarkdownBlock } from "./Blocks/Admin/MarkdownBlock";
|
||||
|
||||
// ─── Block renderer ───────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -25,13 +28,19 @@ function BlockContent({ block, onUpdate }) {
|
||||
/>
|
||||
);
|
||||
case "image":
|
||||
return <ImageBlock content={content} onUpdate={onUpdate} />;
|
||||
return <ImageBlock content={content} onUpdate={onUpdate} />;
|
||||
case "text-image":
|
||||
return <TextImageBlock blockId={id} content={content} onUpdate={onUpdate} />;
|
||||
return <TextImageBlock blockId={id} content={content} onUpdate={onUpdate} />;
|
||||
case "video":
|
||||
return <VideoBlock content={content} onUpdate={onUpdate} />;
|
||||
return <VideoBlock content={content} onUpdate={onUpdate} />;
|
||||
case "text-video":
|
||||
return <TextVideoBlock blockId={id} content={content} onUpdate={onUpdate} />;
|
||||
return <TextVideoBlock blockId={id} content={content} onUpdate={onUpdate} />;
|
||||
case "audio":
|
||||
return <AudioBlock content={content} onUpdate={onUpdate} />;
|
||||
case "code":
|
||||
return <CodeBlock content={content} onUpdate={onUpdate} />;
|
||||
case "markdown":
|
||||
return <MarkdownBlock content={content} onUpdate={onUpdate} />;
|
||||
default:
|
||||
return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
|
||||
}
|
||||
@@ -40,11 +49,14 @@ function BlockContent({ block, onUpdate }) {
|
||||
// ─── Default content per type ─────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_CONTENT = {
|
||||
"text": { body: "" },
|
||||
"image": { asset_id: null, url: "", alt: "" },
|
||||
"text": { body: "" },
|
||||
"image": { asset_id: null, url: "", alt: "" },
|
||||
"text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" },
|
||||
"video": { asset_id: null, url: "", thumbnail_url: "" },
|
||||
"video": { asset_id: null, url: "", thumbnail_url: "" },
|
||||
"text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right" },
|
||||
"audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "" },
|
||||
"code": { language: "javascript", code: "" },
|
||||
"markdown": { body: "" },
|
||||
};
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||
@@ -69,9 +81,9 @@ export function BlockList({ blocks, onUpdate, onMove, onDelete }) {
|
||||
type={block.type}
|
||||
index={index}
|
||||
total={blocks.length}
|
||||
onMoveUp={() => onMove(block.id, "up")}
|
||||
onMoveDown={() => onMove(block.id, "down")}
|
||||
onDelete={() => onDelete(block.id)}
|
||||
onMoveUp={() => onMove(block.id, "up")}
|
||||
onMoveDown={() => onMove(block.id, "down")}
|
||||
onDelete={() => onDelete(block.id)}
|
||||
>
|
||||
<BlockContent
|
||||
block={block}
|
||||
|
||||
@@ -1,83 +1,80 @@
|
||||
// components/generic/CMS/BlockWrapper.jsx
|
||||
|
||||
import { GripVertical, ChevronUp, ChevronDown, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const BLOCK_LABELS = {
|
||||
"text": "Text",
|
||||
"image": "Image",
|
||||
"text-image": "Text + Image",
|
||||
"video": "Video",
|
||||
"text-video": "Text + Video",
|
||||
"text": "Text",
|
||||
"image": "Image",
|
||||
"text-image": "Text + Image",
|
||||
"video": "Video",
|
||||
"text-video": "Text + Video",
|
||||
"audio": "Audio",
|
||||
};
|
||||
|
||||
export function BlockWrapper({
|
||||
type,
|
||||
index,
|
||||
total,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onDelete,
|
||||
children,
|
||||
type,
|
||||
index,
|
||||
total,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onDelete,
|
||||
children,
|
||||
}) {
|
||||
return (
|
||||
<div className="group relative rounded-lg border bg-card transition-shadow hover:shadow-sm">
|
||||
return (
|
||||
<div className="group relative rounded-lg border bg-card transition-shadow hover:shadow-sm">
|
||||
|
||||
{/* ── Top toolbar ── */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 rounded-t-lg">
|
||||
|
||||
{/* Left — drag handle + block type */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{BLOCK_LABELS[type] ?? type}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Block {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right — move + delete */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={index === 0}
|
||||
onClick={onMoveUp}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={index === total - 1}
|
||||
onClick={onMoveDown}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Block content ── */}
|
||||
<div className="p-4">
|
||||
{children}
|
||||
</div>
|
||||
{/* ── Top toolbar ── */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 rounded-t-lg">
|
||||
|
||||
{/* Left — drag handle + block type */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{BLOCK_LABELS[type] ?? type}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Block {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
{/* Right — move + delete */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={index === 0}
|
||||
onClick={onMoveUp}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
disabled={index === total - 1}
|
||||
onClick={onMoveDown}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Block content ── */}
|
||||
<div className="p-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Music2,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
Play,
|
||||
Pause,
|
||||
VolumeOff,
|
||||
Volume2,
|
||||
} from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtTime = (s) => {
|
||||
if (!s || isNaN(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
|
||||
};
|
||||
|
||||
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
// ─── AudioBlock (Admin) ───────────────────────────────────────────────────────
|
||||
|
||||
export function AudioBlock({ content, onUpdate, readOnly = false }) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
// ── S3 token state — fetched when storage_provider is "s3" ───────────────
|
||||
const [streamSrc, setStreamSrc] = useState(null);
|
||||
const [streamThumb, setStreamThumb] = useState(null);
|
||||
const [tokenLoading, setTokenLoading] = useState(false);
|
||||
|
||||
const audioRef = useRef(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [speedIdx, setSpeedIdx] = useState(2); // 1×
|
||||
|
||||
const assetId = content.asset_id ?? null;
|
||||
const storageProvider = content.storage_provider ?? null;
|
||||
const isS3 = storageProvider === "s3";
|
||||
|
||||
// For S3 assets, use the stream URL fetched via admin token.
|
||||
// For all other providers, use the raw url/src from block content.
|
||||
const src = isS3 ? (streamSrc ?? "") : (content.url ?? content.src ?? "");
|
||||
const title = content.title ?? "Audio";
|
||||
const artist = content.artist ?? "";
|
||||
const tag = content.tag ?? "";
|
||||
const thumbnail = isS3 ? (streamThumb ?? content.thumbnail ?? null) : (content.thumbnail ?? null);
|
||||
|
||||
// ── Fetch admin token for S3 assets ───────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!assetId || !isS3) {
|
||||
setStreamSrc(null);
|
||||
setStreamThumb(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setTokenLoading(true);
|
||||
api.post("/admin/media/token", { asset_id: assetId })
|
||||
.then(({ data }) => {
|
||||
if (cancelled) return;
|
||||
const { token, thumbnail_url } = data?.data ?? {};
|
||||
if (token) setStreamSrc(`${API_BASE}/client/media/stream/${token}`);
|
||||
if (thumbnail_url) setStreamThumb(thumbnail_url);
|
||||
})
|
||||
.catch(() => { /* non-fatal — player shows nothing */ })
|
||||
.finally(() => { if (!cancelled) setTokenLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [assetId, isS3]);
|
||||
|
||||
// ── Audio events ──────────────────────────────────────────────────────────
|
||||
|
||||
const onTimeUpdate = useCallback(() => setCurrentTime(audioRef.current?.currentTime ?? 0), []);
|
||||
const onLoadedMeta = useCallback(() => setDuration(audioRef.current?.duration ?? 0), []);
|
||||
const onEnded = useCallback(() => setPlaying(false), []);
|
||||
const onProgress = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (el?.buffered.length && el.duration) {
|
||||
setBuffered((el.buffered.end(el.buffered.length - 1) / el.duration) * 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Controls ──────────────────────────────────────────────────────────────
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
if (playing) { el.pause(); setPlaying(false); }
|
||||
else { el.play(); setPlaying(true); }
|
||||
};
|
||||
|
||||
const seek = (e) => {
|
||||
const el = audioRef.current;
|
||||
const bar = e.currentTarget;
|
||||
const pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth;
|
||||
el.currentTime = pct * duration;
|
||||
};
|
||||
|
||||
const skip = (secs) => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.currentTime = Math.min(Math.max(0, el.currentTime + secs), duration);
|
||||
};
|
||||
|
||||
const handleVolume = (e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
setVolume(v);
|
||||
if (audioRef.current) audioRef.current.volume = v;
|
||||
setMuted(v === 0);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.muted = !muted;
|
||||
setMuted(!muted);
|
||||
};
|
||||
|
||||
const cycleSpeed = () => {
|
||||
const next = (speedIdx + 1) % SPEEDS.length;
|
||||
setSpeedIdx(next);
|
||||
if (audioRef.current) audioRef.current.playbackRate = SPEEDS[next];
|
||||
};
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
// ── Asset picker handler ──────────────────────────────────────────────────
|
||||
//
|
||||
// Stores all metadata needed by the client AudioBlock at render time so
|
||||
// the client never needs a separate API call to fetch asset details.
|
||||
//
|
||||
// Fields saved to block content:
|
||||
// asset_id — used by client for the secure token flow
|
||||
// url — used by admin player (direct src); ignored by client for S3
|
||||
// title — display name (kept if already customised, else asset name)
|
||||
// artist — cleared on new pick so stale artist doesn't carry over
|
||||
// thumbnail — cover art from asset.thumbnail_url
|
||||
// tag — file extension badge e.g. "MP3"
|
||||
//
|
||||
const handleSelect = (asset) => {
|
||||
onUpdate({
|
||||
asset_id: asset.asset_id,
|
||||
// url is null for S3 (redacted server-side); Chibisafe/CDN keeps its raw URL
|
||||
url: asset.file_url ?? null,
|
||||
storage_provider: asset.storage_provider ?? null,
|
||||
title: asset.display_name,
|
||||
artist: "",
|
||||
thumbnail: asset.thumbnail_url ?? null,
|
||||
tag: asset.extension?.toUpperCase() ?? "",
|
||||
});
|
||||
setPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setBuffered(0);
|
||||
setStreamSrc(null);
|
||||
setStreamThumb(null);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{!readOnly && <Label>Audio</Label>}
|
||||
|
||||
{isS3 && tokenLoading ? (
|
||||
<div className="w-full max-w-lg rounded-xl border border-border bg-card flex items-center justify-center h-28">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
|
||||
</div>
|
||||
) : src ? (
|
||||
<div className="w-full max-w-lg rounded-xl overflow-hidden border border-border bg-card text-card-foreground shadow-sm">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={src}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMeta}
|
||||
onEnded={onEnded}
|
||||
onProgress={onProgress}
|
||||
preload="metadata"
|
||||
/>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="relative overflow-hidden">
|
||||
{thumbnail ? (
|
||||
<div
|
||||
className="absolute inset-0 scale-110"
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnail})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
filter: "blur(24px) brightness(0.35)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex items-center gap-4 p-4 text-white">
|
||||
<div className="shrink-0 w-20 h-20 rounded-md overflow-hidden bg-black/25">
|
||||
{thumbnail ? (
|
||||
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music2 className="w-7 h-7 text-white/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
{tag && (
|
||||
<div className="text-xs font-semibold rounded-full uppercase px-2 py-0.5 bg-card text-card-foreground border w-fit">
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-3">{title}</p>
|
||||
{artist && <p className="text-xs text-white/60 line-clamp-2">{artist}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Controls ── */}
|
||||
<div className="px-4 pb-4 pt-3 space-y-3">
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xs tabular-nums text-muted-foreground w-8 shrink-0">
|
||||
{fmtTime(currentTime)}
|
||||
</span>
|
||||
<div
|
||||
className="flex-1 h-1.5 rounded-full bg-muted cursor-pointer relative group"
|
||||
onClick={seek}
|
||||
role="slider"
|
||||
aria-label="Seek"
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-muted-foreground/25 transition-[width] duration-300"
|
||||
style={{ width: `${buffered}%` }}
|
||||
/>
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all relative"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-1/2 w-3 h-3 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
style={{ left: `${progress}%`, transform: "translate(-50%, -50%)" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-muted-foreground w-8 shrink-0 text-right">
|
||||
{fmtTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Button row */}
|
||||
<div className="grid grid-cols-3 items-center">
|
||||
|
||||
{/* Volume */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
aria-label={muted ? "Unmute" : "Mute"}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{muted || volume === 0
|
||||
? <VolumeOff className="size-4" />
|
||||
: <Volume2 className="size-4" />
|
||||
}
|
||||
</button>
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
value={muted ? 0 : volume}
|
||||
onChange={handleVolume}
|
||||
aria-label="Volume"
|
||||
className="w-16 h-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Play controls */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => skip(-10)}
|
||||
aria-label="Rewind 10s"
|
||||
className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<RotateCcw className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
aria-label={playing ? "Pause" : "Play"}
|
||||
className="p-3 rounded-full flex items-center justify-center bg-primary text-primary-foreground hover:opacity-80 transition-opacity active:scale-95 shadow-md"
|
||||
>
|
||||
{playing
|
||||
? <Pause className="size-4" />
|
||||
: <Play className="size-4" />
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => skip(10)}
|
||||
aria-label="Forward 10s"
|
||||
className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<RotateCw className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Speed */}
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
onClick={cycleSpeed}
|
||||
aria-label={`Speed ${SPEEDS[speedIdx]}x`}
|
||||
className="h-7 px-2 rounded text-sm font-medium text-foreground hover:bg-muted transition-colors tabular-nums"
|
||||
>
|
||||
{SPEEDS[speedIdx]}x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Change audio (admin only) ── */}
|
||||
{!readOnly && (
|
||||
<div className="px-4 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="w-full text-sm text-muted-foreground border rounded-md py-1.5 hover:bg-muted transition-colors"
|
||||
>
|
||||
Change audio
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !readOnly && setPickerOpen(true)}
|
||||
className="w-full py-12 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<Music2 className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">Click to select an audio file</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* ── Metadata fields (admin only) ─────────────────────────────────
|
||||
These fields are saved into block content and rendered directly
|
||||
by the client AudioBlock — no extra API call needed at runtime. */}
|
||||
{!readOnly && src && (
|
||||
<div className="space-y-3 pt-1">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audio-title">Title</Label>
|
||||
<Textarea
|
||||
id="audio-title"
|
||||
rows={2}
|
||||
value={content.title ?? ""}
|
||||
onChange={(e) => onUpdate({ ...content, title: e.target.value })}
|
||||
placeholder="Track title"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audio-artist">Artist / Subtitle</Label>
|
||||
<Textarea
|
||||
id="audio-artist"
|
||||
rows={2}
|
||||
value={content.artist ?? ""}
|
||||
onChange={(e) => onUpdate({ ...content, artist: e.target.value })}
|
||||
placeholder="Artist name or subtitle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Asset picker ── */}
|
||||
{!readOnly && (
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="audio"
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const LANGUAGES = [
|
||||
{ value: "html", label: "HTML" },
|
||||
{ value: "css", label: "CSS" },
|
||||
{ value: "javascript", label: "JavaScript" },
|
||||
{ value: "typescript", label: "TypeScript" },
|
||||
{ value: "jsx", label: "JSX / TSX" },
|
||||
{ value: "python", label: "Python" },
|
||||
{ value: "sql", label: "SQL" },
|
||||
{ value: "bash", label: "Shell / Bash" },
|
||||
{ value: "json", label: "JSON" },
|
||||
{ value: "text", label: "Plain Text" },
|
||||
];
|
||||
|
||||
export function CodeBlock({ content, onUpdate }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label>Language</Label>
|
||||
<select
|
||||
value={content.language ?? "javascript"}
|
||||
onChange={(e) => onUpdate({ ...content, language: e.target.value })}
|
||||
className="h-7 text-xs border rounded px-2 bg-background cursor-pointer"
|
||||
>
|
||||
{LANGUAGES.map((l) => (
|
||||
<option key={l.value} value={l.value}>{l.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Code</Label>
|
||||
<Textarea
|
||||
value={content.code ?? ""}
|
||||
onChange={(e) => onUpdate({ ...content, code: e.target.value })}
|
||||
placeholder="// Write or paste your code here..."
|
||||
className="font-mono text-sm min-h-[180px] resize-y leading-relaxed"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
-12
@@ -2,7 +2,7 @@ import { useState } from "react";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { AssetPickerSheet } from "../AssetPickerSheet";
|
||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||
|
||||
|
||||
function MediaPlaceholder({ onClick }) {
|
||||
@@ -20,7 +20,7 @@ function MediaPlaceholder({ onClick }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageBlock({ content, onUpdate }) {
|
||||
export function ImageBlock({ content, onUpdate, readOnly = false }) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -56,16 +56,18 @@ export function ImageBlock({ content, onUpdate }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="image"
|
||||
onSelect={(asset) => onUpdate({
|
||||
...content,
|
||||
asset_id: asset.asset_id,
|
||||
url: asset.file_url,
|
||||
})}
|
||||
/>
|
||||
{!readOnly && (
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="image"
|
||||
onSelect={(asset) => onUpdate({
|
||||
...content,
|
||||
asset_id: asset.asset_id,
|
||||
url: asset.file_url,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Bold, Italic, Heading2, Code, Code2,
|
||||
Link2, List, ListOrdered, Quote, Minus, Eye, Pencil,
|
||||
} from "lucide-react";
|
||||
|
||||
// ─── Shared markdown styles ───────────────────────────────────────────────────
|
||||
// Exported so Client/MarkdownBlock can import and inject the same rules.
|
||||
|
||||
export const MARKDOWN_STYLES = `
|
||||
.md-body { line-height: 1.7; font-size: 1rem; }
|
||||
|
||||
.md-body h1 { font-size: 1.75rem; font-weight: 700; margin: 1.25rem 0 0.5rem; line-height: 1.2; }
|
||||
.md-body h2 { font-size: 1.375rem; font-weight: 600; margin: 1.1rem 0 0.45rem; line-height: 1.25; }
|
||||
.md-body h3 { font-size: 1.125rem; font-weight: 600; margin: 1rem 0 0.4rem; line-height: 1.3; }
|
||||
.md-body h4 { font-size: 1rem; font-weight: 600; margin: 0.9rem 0 0.35rem; }
|
||||
|
||||
.md-body p { margin: 0 0 0.85rem; line-height: 1.75; }
|
||||
|
||||
.md-body ul { list-style: disc; padding-left: 1.4rem; margin: 0.4rem 0 0.85rem; }
|
||||
.md-body ol { list-style: decimal; padding-left: 1.4rem; margin: 0.4rem 0 0.85rem; }
|
||||
.md-body li { margin-bottom: 0.3rem; line-height: 1.7; }
|
||||
|
||||
/* Task list checkboxes */
|
||||
.md-body input[type="checkbox"] { margin-right: 0.4rem; accent-color: hsl(var(--primary)); }
|
||||
|
||||
.md-body a { color: hsl(var(--primary)); text-decoration: underline; }
|
||||
|
||||
.md-body strong { font-weight: 700; }
|
||||
.md-body em { font-style: italic; }
|
||||
.md-body del { text-decoration: line-through; opacity: 0.7; }
|
||||
|
||||
/* Inline code */
|
||||
.md-body :not(pre) > code {
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
|
||||
font-size: 0.875em;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Code block */
|
||||
.md-body pre {
|
||||
background: hsl(220 13% 12%);
|
||||
color: hsl(220 14% 88%);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.75rem 0;
|
||||
border: 1px solid hsl(220 13% 22%);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.md-body pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
color: inherit;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
/* Blockquote */
|
||||
.md-body blockquote {
|
||||
border-left: 3px solid hsl(var(--primary));
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.4rem 0 0.4rem 1rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-style: italic;
|
||||
}
|
||||
.md-body blockquote p { margin-bottom: 0; }
|
||||
|
||||
/* Horizontal rule */
|
||||
.md-body hr {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(var(--border));
|
||||
margin: 1.25rem 0;
|
||||
}
|
||||
|
||||
/* Tables (GFM) — mirrors WYSIWYG table technique: display:block + box-shadow borders */
|
||||
.md-body table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
.md-body th {
|
||||
background: #f1f5f9;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.75rem;
|
||||
white-space: nowrap;
|
||||
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
|
||||
}
|
||||
.md-body td {
|
||||
padding: 0.4rem 0.75rem;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
min-width: 4rem;
|
||||
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
|
||||
}
|
||||
.md-body tbody tr:nth-child(even) td { background: #f8fafc; }
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.md-body { font-size: 1.05rem; }
|
||||
.md-body h1 { font-size: 2rem; }
|
||||
.md-body h2 { font-size: 1.5rem; }
|
||||
.md-body h3 { font-size: 1.25rem; }
|
||||
.md-body table { display: table; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Toolbar button ───────────────────────────────────────────────────────────
|
||||
|
||||
function ToolbarBtn({ title, onClick, children, active }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(e) => { e.preventDefault(); onClick(); }}
|
||||
className={cn(
|
||||
"h-7 w-7 flex items-center justify-center rounded text-muted-foreground shrink-0",
|
||||
"hover:bg-accent hover:text-accent-foreground transition-colors",
|
||||
active && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <span className="w-px h-4 bg-border mx-0.5 shrink-0" />;
|
||||
}
|
||||
|
||||
// ─── MarkdownBlock ────────────────────────────────────────────────────────────
|
||||
|
||||
export function MarkdownBlock({ content, onUpdate }) {
|
||||
const [preview, setPreview] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
const body = content.body ?? "";
|
||||
|
||||
// Insert markdown syntax at cursor, wrapping selection when applicable
|
||||
const insert = (before, after = "", placeholder = "") => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
const selected = body.slice(start, end) || placeholder;
|
||||
const next = body.slice(0, start) + before + selected + after + body.slice(end);
|
||||
onUpdate({ ...content, body: next });
|
||||
// Restore cursor after React re-render
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
const cursor = start + before.length + selected.length + after.length;
|
||||
el.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
};
|
||||
|
||||
const insertLine = (prefix) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const start = el.selectionStart;
|
||||
const lineStart = body.lastIndexOf("\n", start - 1) + 1;
|
||||
const next = body.slice(0, lineStart) + prefix + body.slice(lineStart);
|
||||
onUpdate({ ...content, body: next });
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
el.setSelectionRange(start + prefix.length, start + prefix.length);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Markdown Content</Label>
|
||||
<div className="border rounded-md overflow-hidden focus-within:ring-2 focus-within:ring-ring">
|
||||
|
||||
{/* ── Toolbar ── */}
|
||||
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b bg-muted/40">
|
||||
<ToolbarBtn title="Bold" onClick={() => insert("**", "**", "bold text")}>
|
||||
<Bold className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Italic" onClick={() => insert("*", "*", "italic text")}>
|
||||
<Italic className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
<Divider />
|
||||
<ToolbarBtn title="Heading 2" onClick={() => insertLine("## ")}>
|
||||
<Heading2 className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
<Divider />
|
||||
<ToolbarBtn title="Inline code" onClick={() => insert("`", "`", "code")}>
|
||||
<Code className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Code block" onClick={() => insert("```\n", "\n```", "your code here")}>
|
||||
<Code2 className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
<Divider />
|
||||
<ToolbarBtn title="Link" onClick={() => insert("[", "](url)", "link text")}>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
<Divider />
|
||||
<ToolbarBtn title="Bullet list" onClick={() => insertLine("- ")}>
|
||||
<List className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Numbered list" onClick={() => insertLine("1. ")}>
|
||||
<ListOrdered className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Blockquote" onClick={() => insertLine("> ")}>
|
||||
<Quote className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn title="Horizontal rule" onClick={() => insert("\n---\n", "", "")}>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Preview toggle */}
|
||||
<Divider />
|
||||
<ToolbarBtn
|
||||
title={preview ? "Edit" : "Preview"}
|
||||
active={preview}
|
||||
onClick={() => setPreview((p) => !p)}
|
||||
>
|
||||
{preview ? <Pencil className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
|
||||
{/* ── Edit / Preview ── */}
|
||||
{preview ? (
|
||||
<div className="min-h-[180px] px-3 py-3">
|
||||
<style>{MARKDOWN_STYLES}</style>
|
||||
{body.trim() ? (
|
||||
<div className="md-body text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">Nothing to preview yet.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={body}
|
||||
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
|
||||
placeholder={"# Heading\n\nWrite **markdown** here...\n\n- List item\n- Another item\n\n```js\nconsole.log('hello')\n```"}
|
||||
className="w-full min-h-[180px] resize-y px-3 py-2 text-sm font-mono focus:outline-none bg-background"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+274
-62
@@ -5,10 +5,10 @@ import {
|
||||
Bold, Italic, Underline,
|
||||
AlignLeft, AlignCenter, AlignRight, AlignJustify,
|
||||
List, ListOrdered, Indent, Outdent,
|
||||
Link2, FileText, Table,
|
||||
Link2, FileText, Table, Code, Code2,
|
||||
} from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AssetPickerSheet } from "../AssetPickerSheet";
|
||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── Toolbar button ───────────────────────────────────────────────────────────
|
||||
@@ -48,60 +48,107 @@ const FORMAT_OPTIONS = [
|
||||
// ─── Shared styles ────────────────────────────────────────────────────────────
|
||||
|
||||
export const WYSIWYG_STYLES = `
|
||||
.wysiwyg-editor { line-height: 1.7; }
|
||||
/* ── Base (mobile) ────────────────────────────────────────────────────── */
|
||||
|
||||
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.875rem; font-weight: 700; margin: 1.25rem 0 0.5rem; line-height: 1.2; }
|
||||
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.5rem; font-weight: 600; margin: 1.25rem 0 0.5rem; line-height: 1.3; }
|
||||
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.25rem; font-weight: 600; margin: 1.25rem 0 0.5rem; line-height: 1.4; }
|
||||
.wysiwyg-editor { line-height: 1.6; }
|
||||
|
||||
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.375rem; font-weight: 700; margin: 1rem 0 0.4rem; line-height: 1.2; }
|
||||
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.125rem; font-weight: 600; margin: 1rem 0 0.4rem; line-height: 1.3; }
|
||||
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.4rem; line-height: 1.4; }
|
||||
|
||||
.wysiwyg-editor p,
|
||||
.wysiwyg-preview p {
|
||||
margin: 0 0 0.85rem 0;
|
||||
text-align: justify;
|
||||
line-height: 1.75;
|
||||
margin: 0 0 0.75rem 0;
|
||||
text-align: left;
|
||||
line-height: 1.65;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.wysiwyg-editor ul, .wysiwyg-preview ul { list-style: disc; padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
|
||||
.wysiwyg-editor ol, .wysiwyg-preview ol { list-style: decimal; padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
|
||||
.wysiwyg-editor ul, .wysiwyg-preview ul { list-style: disc; padding-left: 1.1rem; margin: 0.4rem 0 0.75rem; }
|
||||
.wysiwyg-editor ol, .wysiwyg-preview ol { list-style: decimal; padding-left: 1.1rem; margin: 0.4rem 0 0.75rem; }
|
||||
|
||||
.wysiwyg-editor li, .wysiwyg-preview li {
|
||||
margin-bottom: 0.4rem;
|
||||
line-height: 1.75;
|
||||
text-align: justify;
|
||||
margin-bottom: 0.3rem;
|
||||
line-height: 1.65;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.wysiwyg-editor a, .wysiwyg-preview a { color: hsl(var(--primary)); text-decoration: underline; }
|
||||
|
||||
/* Inline code */
|
||||
.wysiwyg-editor code, .wysiwyg-preview code {
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Courier New', Courier, monospace;
|
||||
font-size: 0.875em;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Code block */
|
||||
.wysiwyg-editor pre, .wysiwyg-preview pre {
|
||||
background: hsl(220 13% 12%);
|
||||
color: hsl(220 14% 88%);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 0.75rem 0;
|
||||
border: 1px solid hsl(220 13% 22%);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.wysiwyg-editor pre code, .wysiwyg-preview pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
color: inherit;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.wysiwyg-editor a.doc-link,
|
||||
.wysiwyg-preview a.doc-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
gap: 0.25rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
border: 1px solid hsl(var(--border));
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wysiwyg-editor a.doc-link::before,
|
||||
.wysiwyg-preview a.doc-link::before {
|
||||
content: "📄";
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Table — scrollable on mobile */
|
||||
.wysiwyg-editor table,
|
||||
.wysiwyg-preview table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
table-layout: fixed;
|
||||
margin: 0.6rem 0;
|
||||
font-size: 0.75rem;
|
||||
table-layout: auto;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wysiwyg-editor th,
|
||||
@@ -110,23 +157,21 @@ export const WYSIWYG_STYLES = `
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
word-break: break-word;
|
||||
padding: 0.35rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
|
||||
}
|
||||
|
||||
.wysiwyg-editor td,
|
||||
.wysiwyg-preview td {
|
||||
padding: 0.45rem 0.75rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
min-width: 2rem;
|
||||
min-width: 4rem;
|
||||
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
|
||||
}
|
||||
|
||||
.wysiwyg-preview tr:nth-child(even) td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.wysiwyg-preview tr:nth-child(even) td { background: #f8fafc; }
|
||||
|
||||
.wysiwyg-editor td:focus,
|
||||
.wysiwyg-editor th:focus {
|
||||
@@ -134,6 +179,112 @@ export const WYSIWYG_STYLES = `
|
||||
outline-offset: -2px;
|
||||
background: hsl(var(--accent) / 0.2);
|
||||
}
|
||||
|
||||
|
||||
/* ── Phone (≥ 320px) ─────────────────────────────────────────────────── */
|
||||
|
||||
@media (min-width: 320px) {
|
||||
.wysiwyg-editor { line-height: 1.7; }
|
||||
|
||||
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.625rem; margin: 1.1rem 0 0.45rem; }
|
||||
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.3rem; margin: 1.1rem 0 0.45rem; }
|
||||
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.125rem; margin: 1.1rem 0 0.45rem; }
|
||||
|
||||
.wysiwyg-editor p,
|
||||
.wysiwyg-preview p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
text-align: justify;
|
||||
margin: 0 0 0.8rem 0;
|
||||
}
|
||||
|
||||
.wysiwyg-editor ul, .wysiwyg-preview ul { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; }
|
||||
.wysiwyg-editor ol, .wysiwyg-preview ol { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; }
|
||||
|
||||
.wysiwyg-editor li, .wysiwyg-preview li { font-size: 1rem; line-height: 1.7; margin-bottom: 0.35rem; }
|
||||
|
||||
.wysiwyg-editor a.doc-link,
|
||||
.wysiwyg-preview a.doc-link { font-size: 0.8rem; padding: 0.1rem 0.45rem; gap: 0.28rem; }
|
||||
|
||||
.wysiwyg-editor table,
|
||||
.wysiwyg-preview table { font-size: 0.8125rem; margin: 0.7rem 0; }
|
||||
|
||||
.wysiwyg-editor th,
|
||||
.wysiwyg-preview th { padding: 0.45rem 0.65rem; }
|
||||
|
||||
.wysiwyg-editor td,
|
||||
.wysiwyg-preview td { padding: 0.4rem 0.65rem; min-width: 5rem; }
|
||||
}
|
||||
|
||||
/* ── Tablet (≥ 640px) ─────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.wysiwyg-editor { line-height: 1.7; }
|
||||
|
||||
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.625rem; margin: 1.1rem 0 0.45rem; }
|
||||
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.3rem; margin: 1.1rem 0 0.45rem; }
|
||||
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.125rem; margin: 1.1rem 0 0.45rem; }
|
||||
|
||||
.wysiwyg-editor p,
|
||||
.wysiwyg-preview p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
text-align: justify;
|
||||
margin: 0 0 0.8rem 0;
|
||||
}
|
||||
|
||||
.wysiwyg-editor ul, .wysiwyg-preview ul { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; }
|
||||
.wysiwyg-editor ol, .wysiwyg-preview ol { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; }
|
||||
|
||||
.wysiwyg-editor li, .wysiwyg-preview li { font-size: 1rem; line-height: 1.7; margin-bottom: 0.35rem; }
|
||||
|
||||
.wysiwyg-editor a.doc-link,
|
||||
.wysiwyg-preview a.doc-link { font-size: 0.8rem; padding: 0.1rem 0.45rem; gap: 0.28rem; }
|
||||
|
||||
.wysiwyg-editor table,
|
||||
.wysiwyg-preview table { font-size: 0.8125rem; margin: 0.7rem 0; }
|
||||
|
||||
.wysiwyg-editor th,
|
||||
.wysiwyg-preview th { padding: 0.45rem 0.65rem; }
|
||||
|
||||
.wysiwyg-editor td,
|
||||
.wysiwyg-preview td { padding: 0.4rem 0.65rem; min-width: 5rem; }
|
||||
}
|
||||
|
||||
|
||||
/* ── Desktop (≥ 1024px) ───────────────────────────────────────────────── */
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.875rem; margin: 1.25rem 0 0.5rem; }
|
||||
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.5rem; margin: 1.25rem 0 0.5rem; }
|
||||
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.25rem; margin: 1.25rem 0 0.5rem; }
|
||||
|
||||
.wysiwyg-editor p,
|
||||
.wysiwyg-preview p { font-size: 1.08rem; line-height: 1.75; margin: 0 0 0.85rem 0; }
|
||||
|
||||
.wysiwyg-editor ul, .wysiwyg-preview ul { padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
|
||||
.wysiwyg-editor ol, .wysiwyg-preview ol { padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
|
||||
|
||||
.wysiwyg-editor li, .wysiwyg-preview li { font-size: 1.08em; line-height: 1.75; margin-bottom: 0.4rem; }
|
||||
|
||||
.wysiwyg-editor a.doc-link,
|
||||
.wysiwyg-preview a.doc-link { font-size: 0.8125rem; padding: 0.1rem 0.5rem; gap: 0.3rem; }
|
||||
|
||||
.wysiwyg-editor table,
|
||||
.wysiwyg-preview table {
|
||||
display: table;
|
||||
font-size: 0.875rem;
|
||||
margin: 0.75rem 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.wysiwyg-editor th,
|
||||
.wysiwyg-preview th { padding: 0.5rem 0.75rem; }
|
||||
|
||||
.wysiwyg-editor td,
|
||||
.wysiwyg-preview td { padding: 0.45rem 0.75rem; min-width: 2rem; }
|
||||
}
|
||||
`;
|
||||
|
||||
// ─── Table picker popover ─────────────────────────────────────────────────────
|
||||
@@ -192,13 +343,13 @@ function TablePicker({ onInsert, onClose }) {
|
||||
|
||||
// ─── RichTextEditor ───────────────────────────────────────────────────────────
|
||||
|
||||
export function RichTextEditor({ blockId, value, onChange }) {
|
||||
const editorRef = useRef(null);
|
||||
export function RichTextEditor({ blockId, value, onChange, readOnly = false }) {
|
||||
const editorRef = useRef(null);
|
||||
const initializedFor = useRef(null);
|
||||
const savedRange = useRef(null);
|
||||
const savedRange = useRef(null);
|
||||
const tableButtonRef = useRef(null);
|
||||
|
||||
const [docPickerOpen, setDocPickerOpen] = useState(false);
|
||||
const [docPickerOpen, setDocPickerOpen] = useState(false);
|
||||
const [tablePickerOpen, setTablePickerOpen] = useState(false);
|
||||
|
||||
// ── Seed innerHTML exactly once per blockId ────────────────────────────────
|
||||
@@ -252,7 +403,7 @@ export function RichTextEditor({ blockId, value, onChange }) {
|
||||
|
||||
const handleDocSelect = (asset) => {
|
||||
setDocPickerOpen(false);
|
||||
const url = asset.file_url;
|
||||
const url = asset.file_url;
|
||||
const label = asset.display_name ?? "Document";
|
||||
|
||||
editorRef.current?.focus();
|
||||
@@ -268,6 +419,39 @@ export function RichTextEditor({ blockId, value, onChange }) {
|
||||
savedRange.current = null;
|
||||
};
|
||||
|
||||
// ── Inline code toggle ────────────────────────────────────────────────────
|
||||
// Wraps the selected text in <code>. If the cursor is already inside a
|
||||
// <code> element, unwraps it instead.
|
||||
|
||||
const toggleInlineCode = () => {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
const ancestor = range.commonAncestorContainer;
|
||||
const codeParent = (ancestor.nodeType === 3 ? ancestor.parentElement : ancestor)?.closest("code");
|
||||
|
||||
if (codeParent) {
|
||||
const text = document.createTextNode(codeParent.textContent ?? "");
|
||||
codeParent.replaceWith(text);
|
||||
onChange(editorRef.current.innerHTML);
|
||||
} else {
|
||||
const text = sel.toString();
|
||||
if (!text) return;
|
||||
exec("insertHTML", `<code>${text}</code>`);
|
||||
onChange(editorRef.current.innerHTML);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Code block insertion ───────────────────────────────────────────────────
|
||||
|
||||
const insertCodeBlock = () => {
|
||||
editorRef.current?.focus();
|
||||
const sel = window.getSelection();
|
||||
const selectedText = sel?.toString() || "// your code here";
|
||||
exec("insertHTML", `<pre><code>${selectedText}</code></pre><p><br></p>`);
|
||||
onChange(editorRef.current.innerHTML);
|
||||
};
|
||||
|
||||
// ── Table insertion ────────────────────────────────────────────────────────
|
||||
// Builds a <table> with a header row (th) + (rows-1) data rows.
|
||||
// Each cell is contenteditable (inherited from the editor).
|
||||
@@ -322,21 +506,21 @@ export function RichTextEditor({ blockId, value, onChange }) {
|
||||
|
||||
const GROUPS = [
|
||||
[
|
||||
{ cmd: "bold", Icon: Bold, title: "Bold" },
|
||||
{ cmd: "italic", Icon: Italic, title: "Italic" },
|
||||
{ cmd: "underline", Icon: Underline, title: "Underline" },
|
||||
{ cmd: "bold", Icon: Bold, title: "Bold" },
|
||||
{ cmd: "italic", Icon: Italic, title: "Italic" },
|
||||
{ cmd: "underline", Icon: Underline, title: "Underline" },
|
||||
],
|
||||
[
|
||||
{ cmd: "justifyLeft", Icon: AlignLeft, title: "Align left" },
|
||||
{ cmd: "justifyCenter", Icon: AlignCenter, title: "Align center" },
|
||||
{ cmd: "justifyRight", Icon: AlignRight, title: "Align right" },
|
||||
{ cmd: "justifyFull", Icon: AlignJustify, title: "Justify" },
|
||||
{ cmd: "justifyLeft", Icon: AlignLeft, title: "Align left" },
|
||||
{ cmd: "justifyCenter", Icon: AlignCenter, title: "Align center" },
|
||||
{ cmd: "justifyRight", Icon: AlignRight, title: "Align right" },
|
||||
{ cmd: "justifyFull", Icon: AlignJustify, title: "Justify" },
|
||||
],
|
||||
[
|
||||
{ cmd: "insertUnorderedList", Icon: List, title: "Bullet list" },
|
||||
{ cmd: "insertOrderedList", Icon: ListOrdered, title: "Numbered list" },
|
||||
{ cmd: "indent", Icon: Indent, title: "Indent" },
|
||||
{ cmd: "outdent", Icon: Outdent, title: "Outdent" },
|
||||
{ cmd: "insertUnorderedList", Icon: List, title: "Bullet list" },
|
||||
{ cmd: "insertOrderedList", Icon: ListOrdered, title: "Numbered list" },
|
||||
{ cmd: "indent", Icon: Indent, title: "Indent" },
|
||||
{ cmd: "outdent", Icon: Outdent, title: "Outdent" },
|
||||
],
|
||||
];
|
||||
|
||||
@@ -408,6 +592,24 @@ export function RichTextEditor({ blockId, value, onChange }) {
|
||||
<Table className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Inline code */}
|
||||
<ToolbarBtn
|
||||
title="Inline code"
|
||||
onMouseDown={(e) => { e.preventDefault(); toggleInlineCode(); }}
|
||||
>
|
||||
<Code className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
|
||||
{/* Code block */}
|
||||
<ToolbarBtn
|
||||
title="Code block"
|
||||
onMouseDown={(e) => { e.preventDefault(); insertCodeBlock(); }}
|
||||
>
|
||||
<Code2 className="h-3.5 w-3.5" />
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
|
||||
{/* ── Editable area ── */}
|
||||
@@ -431,27 +633,37 @@ export function RichTextEditor({ blockId, value, onChange }) {
|
||||
)}
|
||||
|
||||
{/* Document asset picker */}
|
||||
<AssetPickerSheet
|
||||
open={docPickerOpen}
|
||||
onOpenChange={setDocPickerOpen}
|
||||
fileType="document"
|
||||
onSelect={handleDocSelect}
|
||||
/>
|
||||
{!readOnly && (
|
||||
<AssetPickerSheet
|
||||
open={docPickerOpen}
|
||||
onOpenChange={setDocPickerOpen}
|
||||
fileType="document"
|
||||
onSelect={handleDocSelect}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── TextBlock ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function TextBlock({ content, onUpdate, blockId }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Content</Label>
|
||||
<RichTextEditor
|
||||
blockId={blockId}
|
||||
value={content.body ?? ""}
|
||||
onChange={(html) => onUpdate({ ...content, body: html })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
export function TextBlock({ content, onUpdate, blockId, readOnly = false }) {
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body ?? "" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label>Content</Label>
|
||||
<RichTextEditor
|
||||
blockId={blockId}
|
||||
value={content.body ?? ""}
|
||||
onChange={(html) => onUpdate({ ...content, body: html })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
-12
@@ -12,9 +12,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
|
||||
import { AssetPickerSheet } from "../AssetPickerSheet";
|
||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||
|
||||
export function TextImageBlock({ content, onUpdate, blockId }) {
|
||||
export function TextImageBlock({ content, onUpdate, blockId, readOnly = false }) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -92,16 +92,18 @@ export function TextImageBlock({ content, onUpdate, blockId }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="image"
|
||||
onSelect={(asset) => onUpdate({
|
||||
...content,
|
||||
asset_id: asset.asset_id,
|
||||
url: asset.file_url,
|
||||
})}
|
||||
/>
|
||||
{!readOnly && (
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="image"
|
||||
onSelect={(asset) => onUpdate({
|
||||
...content,
|
||||
asset_id: asset.asset_id,
|
||||
url: asset.file_url,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+15
-13
@@ -11,9 +11,9 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
|
||||
import { AssetPickerSheet } from "../AssetPickerSheet";
|
||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||
|
||||
export function TextVideoBlock({ content, onUpdate, blockId }) {
|
||||
export function TextVideoBlock({ content, onUpdate, blockId, readOnly = false }) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const thumb = content.thumbnail_url ?? null;
|
||||
@@ -93,17 +93,19 @@ export function TextVideoBlock({ content, onUpdate, blockId }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="video"
|
||||
onSelect={(asset) => onUpdate({
|
||||
...content,
|
||||
asset_id: asset.asset_id,
|
||||
url: asset.file_url,
|
||||
thumbnail_url: asset.thumbnail_url ?? null,
|
||||
})}
|
||||
/>
|
||||
{!readOnly && (
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="video"
|
||||
onSelect={(asset) => onUpdate({
|
||||
...content,
|
||||
asset_id: asset.asset_id,
|
||||
url: asset.file_url,
|
||||
thumbnail_url: asset.thumbnail_url ?? null,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
SkipBack,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Maximize2,
|
||||
VideoIcon,
|
||||
} from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AssetPickerSheet } from "../../AssetPickerSheet";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtTime = (s) => {
|
||||
if (!s || isNaN(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
|
||||
};
|
||||
|
||||
// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
|
||||
|
||||
export function VideoBlock({ content, onUpdate, readOnly = false }) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
|
||||
const vidRef = useRef(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [totalDuration, setTotalDuration] = useState(0);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [overlayVisible,setOverlayVisible]= useState(true);
|
||||
|
||||
// Reset player when video changes
|
||||
useEffect(() => {
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
setTotalDuration(0);
|
||||
setOverlayVisible(true);
|
||||
}, [content.url]);
|
||||
|
||||
// ── Video event listeners ─────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setCurrentTime(v.currentTime);
|
||||
if (v.duration) setProgress((v.currentTime / v.duration) * 100);
|
||||
};
|
||||
const onLoaded = () => setTotalDuration(v.duration);
|
||||
const onEnded = () => { setPlaying(false); setOverlayVisible(true); };
|
||||
|
||||
v.addEventListener("timeupdate", onTimeUpdate);
|
||||
v.addEventListener("loadedmetadata", onLoaded);
|
||||
v.addEventListener("ended", onEnded);
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("timeupdate", onTimeUpdate);
|
||||
v.removeEventListener("loadedmetadata", onLoaded);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
};
|
||||
}, [content.url]);
|
||||
|
||||
// ── Controls ──────────────────────────────────────────────────────────────
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) { v.play(); setPlaying(true); setOverlayVisible(false); }
|
||||
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
|
||||
}, []);
|
||||
|
||||
const restart = () => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
setPlaying(false);
|
||||
setOverlayVisible(true);
|
||||
};
|
||||
|
||||
const handleSeek = (e) => {
|
||||
const v = vidRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = (parseFloat(e.target.value) / 100) * v.duration;
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setVolume(val);
|
||||
if (vidRef.current) { vidRef.current.volume = val; vidRef.current.muted = val === 0; }
|
||||
setMuted(val === 0);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
v.muted = !v.muted;
|
||||
setMuted(v.muted);
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = document.getElementById("video-block-wrap");
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else el.requestFullscreen?.();
|
||||
};
|
||||
|
||||
// ── Asset picker handler ──────────────────────────────────────────────────
|
||||
//
|
||||
// Saves all metadata needed by the client VideoBlock at render time.
|
||||
// Clean object — no ...content spread so stale data never carries over.
|
||||
//
|
||||
// Fields saved:
|
||||
// asset_id — used by client for the secure token flow
|
||||
// url — used by admin player (direct src); ignored by client for S3
|
||||
// thumbnail_url — poster image for the video player
|
||||
// title — display name (shown in any title-aware blocks)
|
||||
// tag — file extension badge e.g. "MP4"
|
||||
//
|
||||
const handleSelect = (asset) => {
|
||||
onUpdate({
|
||||
asset_id: asset.asset_id,
|
||||
url: asset.file_url,
|
||||
thumbnail_url: asset.thumbnail_url ?? null,
|
||||
title: asset.display_name,
|
||||
tag: asset.extension?.toUpperCase() ?? "",
|
||||
});
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
setTotalDuration(0);
|
||||
setOverlayVisible(true);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{!readOnly && <Label>Video</Label>}
|
||||
|
||||
{content.url ? (
|
||||
<div className="rounded-lg overflow-hidden bg-card">
|
||||
|
||||
{/* ── Video area ── */}
|
||||
<div
|
||||
id="video-block-wrap"
|
||||
className="relative w-full bg-black cursor-pointer group"
|
||||
style={{ aspectRatio: "16/9" }}
|
||||
onClick={togglePlay}
|
||||
>
|
||||
<video
|
||||
ref={vidRef}
|
||||
src={content.url}
|
||||
poster={content.thumbnail_url ?? undefined}
|
||||
preload="metadata"
|
||||
playsInline
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
|
||||
{/* Play/pause overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${overlayVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ background: "rgba(0,0,0,0.3)" }}
|
||||
>
|
||||
<button
|
||||
aria-label={playing ? "Pause" : "Play"}
|
||||
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
|
||||
className="w-12 h-12 rounded-full bg-white/90 hover:bg-white flex items-center justify-center transition-transform hover:scale-105"
|
||||
>
|
||||
{playing
|
||||
? <Pause className="size-4 text-black" />
|
||||
: <Play className="size-4 text-black ml-0.5" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Change video hover hint */}
|
||||
{!readOnly && (
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
className="text-xs bg-black/60 hover:bg-black/80 text-white px-2.5 py-1 rounded-md transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); setPickerOpen(true); }}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Player controls ── */}
|
||||
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-2">
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="relative h-1 bg-border rounded-full cursor-pointer">
|
||||
<div
|
||||
className="h-full bg-foreground rounded-full transition-[width] duration-100"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range" min="0" max="100" step="0.1"
|
||||
value={progress}
|
||||
onChange={handleSeek}
|
||||
aria-label="Seek"
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Button row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
|
||||
</button>
|
||||
<button onClick={restart} aria-label="Restart" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<SkipBack className="size-4" />
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<button onClick={toggleMute} aria-label="Toggle mute" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
|
||||
</button>
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
value={muted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
aria-label="Volume"
|
||||
className="w-16 accent-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button onClick={toggleFullscreen} aria-label="Fullscreen" className="text-muted-foreground hover:text-foreground transition-colors ml-1">
|
||||
<Maximize2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Change video footer (admin only) ── */}
|
||||
{!readOnly && (
|
||||
<div className="px-3 pb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="w-full text-sm text-muted-foreground border rounded-md py-1.5 hover:bg-muted transition-colors"
|
||||
>
|
||||
Change video
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="w-full aspect-video rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<VideoIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">Click to select a video</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="video"
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// components/blocks/Banner.jsx
|
||||
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// Height per size — controls the strip's visual weight, not its width (always full-width).
|
||||
const SIZE_HEIGHT = {
|
||||
sm: "h-20",
|
||||
md: "h-32",
|
||||
lg: "h-48",
|
||||
};
|
||||
|
||||
// ── Banner ───────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Generic banner advertisement block.
|
||||
* Full-width horizontal strip, image-led with optional headline overlay.
|
||||
* Click anywhere on the banner triggers the first available CTA (or just tracks
|
||||
* the click if no CTA exists) — banners don't carry their own button row.
|
||||
*
|
||||
* Props:
|
||||
* ad — advertisement object { headline, ctas, image, image_url, advertisement_id }
|
||||
* size — "sm" | "md" | "lg" (default "md")
|
||||
* onCtaClick — (ad, cta) => void, called on click. cta may be undefined if the ad has none.
|
||||
*/
|
||||
export function Banner({ ad, size, onCtaClick }) {
|
||||
if (!ad) return null;
|
||||
|
||||
const imageSrc = ad.image?.file_url || ad.image_url || null;
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
const resolvedSize = ad.size || size || "md";
|
||||
const heightClass = SIZE_HEIGHT[resolvedSize] ?? SIZE_HEIGHT.md;
|
||||
|
||||
const handleClick = () => onCtaClick?.(ad, ctas[0]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={`relative w-full rounded-lg bg-muted overflow-hidden flex items-center justify-center text-left ${heightClass}`}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Megaphone className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{ad.headline && (
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 flex items-end p-4">
|
||||
<p className="text-white font-medium text-sm sm:text-base">{ad.headline}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── BannerSkeleton ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BannerSkeleton({ size = "md" }) {
|
||||
return <Skeleton className={`w-full rounded-lg ${SIZE_HEIGHT[size] ?? SIZE_HEIGHT.md}`} />;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// components/blocks/Hero.jsx
|
||||
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// ── Hero ─────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Generic hero advertisement block.
|
||||
* Two-column layout: badge/headline/description/CTAs on the left, image on the right.
|
||||
* Renders null when no ad is provided — callers should not fall back to placeholder copy.
|
||||
*
|
||||
* Props:
|
||||
* ad — advertisement object { badge_label, headline, description, ctas, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
|
||||
*/
|
||||
export function Hero({ ad, onCtaClick }) {
|
||||
if (!ad) return null;
|
||||
|
||||
const imageSrc = ad.image?.file_url || ad.image_url || null;
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
|
||||
return (
|
||||
<div className="flex xs:flex-col lg:flex-row items-center gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
{ad.badge_label && (
|
||||
<Badge variant="outline">
|
||||
<Megaphone /> {ad.badge_label}
|
||||
</Badge>
|
||||
)}
|
||||
{ad.headline && (
|
||||
<div className="font-bold text-4xl leading-12">
|
||||
{ad.headline}
|
||||
</div>
|
||||
)}
|
||||
{ad.description && (
|
||||
<p className="max-w-lg">
|
||||
{ad.description}
|
||||
</p>
|
||||
)}
|
||||
{ctas.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
{ctas.map((cta, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant={cta.variant === "outline" ? "outline" : "default"}
|
||||
onClick={() => onCtaClick?.(ad, cta)}
|
||||
>
|
||||
{cta.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted w-xl aspect-video flex items-center justify-center overflow-hidden">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Megaphone className="size-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── HeroSkeleton ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function HeroSkeleton() {
|
||||
return (
|
||||
<div className="flex xs:flex-col lg:flex-row items-center gap-6">
|
||||
<div className="flex flex-col gap-3 w-full max-w-lg">
|
||||
<Skeleton className="h-6 w-32 rounded-full" />
|
||||
<Skeleton className="h-10 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="rounded-lg w-xl aspect-video" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// components/blocks/Popup.jsx
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
|
||||
// ── Popup ────────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Generic popup advertisement block.
|
||||
* Modal-style placement shown on page load — wraps ResponsiveModal so it gets
|
||||
* dialog/drawer behavior for free. Caller owns the `open` state (typically set
|
||||
* to true once an active popup ad resolves from the API).
|
||||
*
|
||||
* Props:
|
||||
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
|
||||
* open — boolean, modal visibility
|
||||
* onOpenChange — (open: boolean) => void
|
||||
* onCtaClick — (ad, cta) => void, called when a footer CTA button is clicked
|
||||
*/
|
||||
export function Popup({ ad, open, onOpenChange, onCtaClick }) {
|
||||
if (!ad) return null;
|
||||
|
||||
const imageSrc = ad.image?.file_url || ad.image_url || null;
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={ad.headline || "Announcement"}
|
||||
description={ad.description || undefined}
|
||||
footer={
|
||||
ctas.length > 0 ? (
|
||||
<>
|
||||
{ctas.map((cta, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant={cta.variant === "outline" ? "outline" : "default"}
|
||||
onClick={() => onCtaClick?.(ad, cta)}
|
||||
>
|
||||
{cta.label}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{imageSrc && (
|
||||
<div className="rounded-lg bg-muted aspect-video flex items-center justify-center overflow-hidden">
|
||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
</ResponsiveModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// components/blocks/Sidebar.jsx
|
||||
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// ── Sidebar ──────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Generic sidebar advertisement block.
|
||||
* Compact vertical card — image on top, optional short headline/description and
|
||||
* a single CTA below. Meant to sit in a narrow column (sidebars, rail layouts),
|
||||
* not stretch full-width like Hero/Banner.
|
||||
*
|
||||
* Props:
|
||||
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
|
||||
* onCtaClick — (ad, cta) => void, called when the CTA button (or card, if no CTA) is clicked
|
||||
*/
|
||||
export function Sidebar({ ad, onCtaClick }) {
|
||||
if (!ad) return null;
|
||||
|
||||
const imageSrc = ad.image?.file_url || ad.image_url || null;
|
||||
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
|
||||
const primaryCta = ctas[0];
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (!primaryCta) onCtaClick?.(ad, undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full max-w-xs rounded-lg border bg-card overflow-hidden flex flex-col cursor-pointer"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="aspect-square bg-muted flex items-center justify-center overflow-hidden">
|
||||
{imageSrc ? (
|
||||
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Megaphone className="size-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(ad.headline || ad.description || primaryCta) && (
|
||||
<div className="p-3 flex flex-col gap-1.5">
|
||||
{ad.headline && <p className="text-sm font-medium leading-snug">{ad.headline}</p>}
|
||||
{ad.description && <p className="text-xs text-muted-foreground line-clamp-2">{ad.description}</p>}
|
||||
{primaryCta && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-1 w-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCtaClick?.(ad, primaryCta);
|
||||
}}
|
||||
>
|
||||
{primaryCta.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── SidebarSkeleton ──────────────────────────────────────────────────────────
|
||||
|
||||
export function SidebarSkeleton() {
|
||||
return (
|
||||
<div className="w-full max-w-xs rounded-lg border bg-card overflow-hidden flex flex-col">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<div className="p-3 flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-8 w-full mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtTime = (s) => {
|
||||
if (!s || isNaN(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
|
||||
};
|
||||
|
||||
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
||||
|
||||
// ─── AudioBlock (Client — secure) ────────────────────────────────────────────
|
||||
//
|
||||
// S3/Garage:
|
||||
// 1. POST /client/media/token → get JWT token
|
||||
// 2. fetch(streamUrl, { credentials: "include" }) → get raw bytes
|
||||
// 3. URL.createObjectURL(blob) → blob:http://... URL
|
||||
// 4. <audio src="blob:..."> → real URL never visible in DOM
|
||||
//
|
||||
// Chibisafe:
|
||||
// → content.url used directly (Chibisafe CDN, no proxy needed)
|
||||
//
|
||||
// Direct (legacy):
|
||||
// → content.url / content.src used directly, no token flow
|
||||
//
|
||||
// content shape: { asset_id?, url?, storage_provider?, title?, artist?, tag?, thumbnail? }
|
||||
|
||||
export function AudioBlock({ content }) {
|
||||
const audioRef = useRef(null);
|
||||
|
||||
// ── Stream state ──────────────────────────────────────────────────────────
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState(null);
|
||||
const [fetchLoading, setFetchLoading] = useState(false);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
|
||||
// ── Player state ──────────────────────────────────────────────────────────
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [speedIdx, setSpeedIdx] = useState(2); // 1×
|
||||
|
||||
const assetId = content?.asset_id;
|
||||
const storageProvider = content?.storage_provider;
|
||||
const directUrl = content?.url ?? content?.src ?? null;
|
||||
const title = content?.title ?? "Audio";
|
||||
const artist = content?.artist ?? "";
|
||||
const tag = content?.tag ?? "";
|
||||
// For S3 assets, thumbnailUrl is set from the token response (presigned URL).
|
||||
// For other providers, fall back to the raw content.thumbnail value.
|
||||
const thumbnail = thumbnailUrl ?? content?.thumbnail ?? null;
|
||||
|
||||
// ── Resolve stream URL → set as audio src directly ───────────────────────
|
||||
useEffect(() => {
|
||||
setBlobUrl(null);
|
||||
setThumbnailUrl(null);
|
||||
setFetchError(false);
|
||||
setPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
|
||||
// Legacy direct URL — no asset_id
|
||||
if (!assetId && directUrl) {
|
||||
setBlobUrl(directUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!assetId) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setFetchLoading(true);
|
||||
try {
|
||||
// Chibisafe — use raw URL directly (CDN is public, no token needed)
|
||||
if (storageProvider === "chibisafe") {
|
||||
if (!directUrl) throw new Error("No URL in content");
|
||||
if (!cancelled) setBlobUrl(directUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// S3 — get token; response also includes presigned thumbnail URL
|
||||
const { data } = await api.post("/client/media/token", { asset_id: assetId });
|
||||
if (cancelled) return;
|
||||
const { token, thumbnail_url } = data?.data ?? {};
|
||||
if (!token) throw new Error("No token returned");
|
||||
|
||||
if (!cancelled) {
|
||||
setBlobUrl(`${API_BASE}/client/media/stream/${token}`);
|
||||
if (thumbnail_url) setThumbnailUrl(thumbnail_url);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error("[AudioBlock] load failed", err);
|
||||
setFetchError(true);
|
||||
} finally {
|
||||
if (!cancelled) setFetchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [assetId, storageProvider, directUrl]);
|
||||
|
||||
// ── Audio events ──────────────────────────────────────────────────────────
|
||||
|
||||
const onTimeUpdate = useCallback(() => setCurrentTime(audioRef.current?.currentTime ?? 0), []);
|
||||
const onLoadedMeta = useCallback(() => setDuration(audioRef.current?.duration ?? 0), []);
|
||||
const onEnded = useCallback(() => setPlaying(false), []);
|
||||
const onProgress = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (el?.buffered.length && el.duration) {
|
||||
setBuffered((el.buffered.end(el.buffered.length - 1) / el.duration) * 100);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Controls ──────────────────────────────────────────────────────────────
|
||||
|
||||
const togglePlay = () => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
if (playing) { el.pause(); setPlaying(false); }
|
||||
else { el.play(); setPlaying(true); }
|
||||
};
|
||||
|
||||
const seek = (e) => {
|
||||
const el = audioRef.current;
|
||||
const bar = e.currentTarget;
|
||||
const pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth;
|
||||
el.currentTime = pct * duration;
|
||||
};
|
||||
|
||||
const skip = (secs) => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.currentTime = Math.min(Math.max(0, el.currentTime + secs), duration);
|
||||
};
|
||||
|
||||
const handleVolume = (e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
setVolume(v);
|
||||
if (audioRef.current) audioRef.current.volume = v;
|
||||
setMuted(v === 0);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.muted = !muted;
|
||||
setMuted(!muted);
|
||||
};
|
||||
|
||||
const cycleSpeed = () => {
|
||||
const next = (speedIdx + 1) % SPEEDS.length;
|
||||
setSpeedIdx(next);
|
||||
if (audioRef.current) audioRef.current.playbackRate = SPEEDS[next];
|
||||
};
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
// ── States ────────────────────────────────────────────────────────────────
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div className="w-full rounded-xl border border-border bg-card flex items-center justify-center h-28">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div className="w-full rounded-xl border border-border bg-card flex items-center justify-center h-24 gap-2 text-muted-foreground text-sm">
|
||||
<Music2 className="size-4" /> Audio unavailable.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!blobUrl) return null;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="w-full rounded-xl overflow-hidden border border-border bg-card text-card-foreground shadow-sm">
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={blobUrl}
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMeta}
|
||||
onEnded={onEnded}
|
||||
onProgress={onProgress}
|
||||
preload="auto"
|
||||
/>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="xs:opacity-0 lg:opacity-100 select-none absolute top-4 right-5 z-20">
|
||||
<img src="/philpro-white-single.png" alt="Logo" className="h-6 w-auto" />
|
||||
</div>
|
||||
|
||||
{thumbnail ? (
|
||||
<div
|
||||
className="absolute inset-0 scale-110"
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnail})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
filter: "blur(24px) brightness(0.35)",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-muted" />
|
||||
)}
|
||||
|
||||
{/* Mobile */}
|
||||
<div className="relative z-10 flex flex-col gap-4 pb-6 text-white sm:hidden">
|
||||
<div className="w-full px-4 pt-4">
|
||||
<div className="w-full h-72 aspect-square rounded-lg overflow-hidden bg-black/30 shadow-xl dark:border">
|
||||
{thumbnail ? (
|
||||
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music2 className="w-10 h-10 text-white/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-4">
|
||||
{tag && (
|
||||
<div className="text-xs font-semibold rounded-full uppercase px-3 py-1 bg-card text-card-foreground border w-fit mb-1">
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-lg font-semibold leading-tight">{title}</p>
|
||||
{artist && <p className="text-sm text-white/60">{artist}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop */}
|
||||
<div className="relative z-10 hidden sm:flex items-center gap-4 p-4 text-white">
|
||||
<div className="shrink-0 w-48 h-48 rounded-md overflow-hidden bg-black/25">
|
||||
{thumbnail ? (
|
||||
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Music2 className="w-7 h-7 text-white/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
{tag && (
|
||||
<div className="text-xs font-semibold rounded-full uppercase px-3 py-1 bg-card text-card-foreground border w-fit">
|
||||
{tag}
|
||||
</div>
|
||||
)}
|
||||
<p className="w-sm line-clamp-3 text-lg font-semibold leading-tight">{title}</p>
|
||||
{artist && <p className="text-sm text-white/60 truncate">{artist}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Controls ── */}
|
||||
<div className="px-4 pb-4 pt-3 space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="text-xs tabular-nums text-card-foreground w-8 shrink-0">
|
||||
{fmtTime(currentTime)}
|
||||
</span>
|
||||
<div
|
||||
className="flex-1 h-1.5 rounded-full bg-muted cursor-pointer relative group"
|
||||
onClick={seek}
|
||||
role="slider"
|
||||
aria-label="Seek"
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div className="absolute inset-y-0 left-0 rounded-full bg-muted-foreground/25 transition-[width] duration-300" style={{ width: `${buffered}%` }} />
|
||||
<div className="h-full rounded-full bg-primary transition-all relative" style={{ width: `${progress}%` }} />
|
||||
<div className="absolute top-1/2 w-3 h-3 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%`, transform: "translate(-50%, -50%)" }} />
|
||||
</div>
|
||||
<span className="text-xs tabular-nums text-card-foreground w-8 shrink-0 text-right">
|
||||
{fmtTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 items-center">
|
||||
{/* Left — volume */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={toggleMute} aria-label={muted ? "Unmute" : "Mute"} className="w-7 h-7 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
|
||||
{muted || volume === 0 ? <VolumeOff className="size-4" /> : <Volume2 className="size-4" />}
|
||||
</button>
|
||||
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolume} aria-label="Volume" className="w-16 h-1.5" />
|
||||
</div>
|
||||
|
||||
{/* Center — play controls */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button onClick={() => skip(-10)} aria-label="Rewind 10 seconds" className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
|
||||
<RotateCcw className="size-4" />
|
||||
</button>
|
||||
<button onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} className="p-3 rounded-full flex items-center justify-center bg-primary text-primary-foreground hover:opacity-80 transition-opacity active:scale-95 shadow-md">
|
||||
{playing ? <Pause className="xs:size-4 lg:size-5" /> : <Play className="xs:size-4 lg:size-5" />}
|
||||
</button>
|
||||
<button onClick={() => skip(10)} aria-label="Forward 10 seconds" className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
|
||||
<RotateCw className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right — speed */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={cycleSpeed} aria-label={`Playback speed ${SPEEDS[speedIdx]}x`} className="h-7 px-2 rounded text-sm font-medium text-foreground hover:bg-muted transition-colors tabular-nums">
|
||||
{SPEEDS[speedIdx]}x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from "react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
export function CodeBlock({ content }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const code = content.code ?? "";
|
||||
const language = content.language ?? "text";
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (!code) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground italic">
|
||||
Empty code block
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden border border-zinc-700 dark:border-zinc-600 bg-zinc-950 text-zinc-100 my-2">
|
||||
{/* ── Header bar ── */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-700">
|
||||
<span className="text-[11px] font-mono text-zinc-400 uppercase tracking-widest select-none">
|
||||
{language}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
{copied
|
||||
? <Check className="size-3.5 text-emerald-400" />
|
||||
: <Copy className="size-3.5" />
|
||||
}
|
||||
<span>{copied ? "Copied!" : "Copy"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Code area ── */}
|
||||
<pre className="overflow-x-auto p-4 text-sm leading-relaxed" style={{ margin: 0, background: "transparent" }}>
|
||||
<code className="font-mono whitespace-pre">{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { ZoomableImage } from "@/modules/admin/components/courses/LessonsPreview";
|
||||
|
||||
export function ImageBlock({ content }) {
|
||||
if (!content.url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
No image
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <ZoomableImage url={content.url} alt={content.alt} />;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { MARKDOWN_STYLES } from "@/components/generic/Blocks/Admin/MarkdownBlock";
|
||||
|
||||
export function MarkdownBlock({ content }) {
|
||||
const body = content.body ?? "";
|
||||
|
||||
if (!body.trim()) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground italic py-2">
|
||||
Empty markdown block
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{MARKDOWN_STYLES}</style>
|
||||
<div className="md-body">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
|
||||
|
||||
export function TextBlock({ content }) {
|
||||
if (!content.body) {
|
||||
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<style>{WYSIWYG_STYLES}</style>
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
|
||||
import { ImageBlock } from "./ImageBlock";
|
||||
|
||||
export function TextImageBlock({ content }) {
|
||||
const imgLeft = content.image_position === "left";
|
||||
return (
|
||||
<>
|
||||
<style>{WYSIWYG_STYLES}</style>
|
||||
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
||||
{imgLeft && <ImageBlock content={content} />}
|
||||
<div
|
||||
className="wysiwyg-preview text-sm w-full"
|
||||
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
|
||||
/>
|
||||
{!imgLeft && <ImageBlock content={content} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
|
||||
import { VideoBlock } from "./VideoBlock";
|
||||
|
||||
export function TextVideoBlock({ content }) {
|
||||
const vidLeft = content.video_position === "left";
|
||||
return (
|
||||
<>
|
||||
<style>{WYSIWYG_STYLES}</style>
|
||||
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
||||
{vidLeft && <VideoBlock content={content} />}
|
||||
<div
|
||||
className="wysiwyg-preview text-sm w-full"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
|
||||
}}
|
||||
/>
|
||||
{!vidLeft && <VideoBlock content={content} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Play, Pause, SkipBack, Volume2, VolumeX, Maximize2, Minimize2, Settings, VideoIcon,
|
||||
Volume1, SkipForward, Gauge, X, RotateCcw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fmtTime = (s) => {
|
||||
if (!s || isNaN(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
|
||||
};
|
||||
|
||||
const PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"];
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
||||
|
||||
// ─── Tooltip control button ───────────────────────────────────────────────────
|
||||
|
||||
function CtrlBtn({ label, onClick, children, className = "" }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={`text-white/80 hover:text-white transition-colors flex items-center justify-center ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-sm">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Settings panel ───────────────────────────────────────────────────────────
|
||||
|
||||
function SettingsPanel({ speed, onSpeed, onClose }) {
|
||||
const [tab, setTab] = useState(null);
|
||||
|
||||
const Row = ({ icon: Icon, label, value, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-white/10 transition-colors text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2.5 text-white/90">
|
||||
<Icon className="size-4 text-white/50" />
|
||||
{label}
|
||||
</span>
|
||||
<div className="text-white/50 flex items-center gap-2">
|
||||
<p>{value}</p><ChevronRight className="size-4" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
const OptionList = ({ options, current, onSelect }) => (
|
||||
<div className="py-1">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt} type="button"
|
||||
onClick={() => { onSelect(opt); setTab(null); }}
|
||||
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-white/10 flex items-center justify-between ${current === opt ? "text-white font-medium" : "text-white/60"}`}
|
||||
>
|
||||
{opt}
|
||||
{current === opt && <span className="text-white text-xs">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="hidden lg:block absolute bottom-12 right-2 z-20 w-70 rounded-xl overflow-hidden shadow-2xl border border-white/10"
|
||||
style={{ background: "rgba(18,18,18,0.96)", backdropFilter: "blur(16px)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{tab === null && (
|
||||
<>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/30 px-4 pt-3 pb-1">Settings</p>
|
||||
<Row icon={Gauge} label="Playback speed" value={speed} onClick={() => setTab("speed")} />
|
||||
<div className="h-2" />
|
||||
</>
|
||||
)}
|
||||
{tab !== null && (
|
||||
<>
|
||||
<button type="button" onClick={() => setTab(null)} className="flex items-center gap-1.5 px-4 pt-3 pb-1 text-sm text-white/40 hover:text-white/70 transition-colors w-full">
|
||||
<ChevronLeft className="size-4" /> Playback speed
|
||||
</button>
|
||||
<OptionList options={PLAYBACK_SPEEDS} current={speed} onSelect={onSpeed} />
|
||||
<div className="h-1" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="lg:hidden absolute bottom-0 left-0 right-0 z-20 rounded-t-2xl border-t border-white/10 overflow-hidden"
|
||||
style={{ background: "rgba(18,18,18,0.98)", backdropFilter: "blur(20px)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 pt-2.5 pb-1">
|
||||
<div className="w-8 h-1 rounded-full bg-white/20 mx-auto" />
|
||||
<button type="button" onClick={(e) => { e.stopPropagation(); onClose(); }} className="absolute right-3 top-3 text-white/40 hover:text-white transition-colors">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{tab === null && (
|
||||
<>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/30 px-4 pt-2 pb-1">Settings</p>
|
||||
<Row icon={Gauge} label="Playback speed" value={speed} onClick={() => setTab("speed")} />
|
||||
<div className="h-safe pb-4" />
|
||||
</>
|
||||
)}
|
||||
{tab !== null && (
|
||||
<>
|
||||
<button type="button" onClick={() => setTab(null)} className="flex items-center gap-1.5 px-4 pt-3 pb-1 text-sm text-white/40 hover:text-white/70 transition-colors w-full">
|
||||
<ChevronLeft className="size-4" /> Playback speed
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2 px-4 py-3">
|
||||
{PLAYBACK_SPEEDS.map((opt) => (
|
||||
<button
|
||||
key={opt} type="button"
|
||||
onClick={() => { onSpeed(opt); setTab(null); }}
|
||||
className={`px-4 py-1.5 rounded-full text-sm border transition-colors ${speed === opt ? "bg-white text-black border-white font-medium" : "bg-white/10 text-white/70 border-white/10 hover:bg-white/20"}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="pb-4" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── VideoBlock (Client — secure) ────────────────────────────────────────────
|
||||
//
|
||||
// S3/Garage:
|
||||
// 1. POST /client/media/token → get JWT token
|
||||
// 2. fetch(streamUrl, { credentials: "include" }) → get raw bytes
|
||||
// 3. URL.createObjectURL(blob) → blob:http://... URL
|
||||
// 4. <video src="blob:..."> → real URL never visible in DOM
|
||||
//
|
||||
// Chibisafe:
|
||||
// → content.url used directly (Chibisafe CDN, no proxy needed)
|
||||
//
|
||||
// content shape: { asset_id, url, storage_provider, thumbnail_url? }
|
||||
|
||||
export function VideoBlock({ content }) {
|
||||
const wrapRef = useRef(null);
|
||||
const vidRef = useRef(null);
|
||||
|
||||
// ── Stream state ──────────────────────────────────────────────────────────
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const [fetchLoading, setFetchLoading] = useState(false);
|
||||
const [fetchError, setFetchError] = useState(false);
|
||||
|
||||
// ── Player state ──────────────────────────────────────────────────────────
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [totalDuration, setTotalDuration] = useState(0);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [overlayVisible, setOverlayVisible] = useState(true);
|
||||
const [controlsVisible, setControlsVisible] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [volumePanelOpen, setVolumePanelOpen] = useState(false);
|
||||
const [keyFeedback, setKeyFeedback] = useState(null);
|
||||
const [speed, setSpeed] = useState("Normal");
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [buffering, setBuffering] = useState(false);
|
||||
const [hoverProgress, setHoverProgress] = useState(null);
|
||||
|
||||
const hideTimer = useRef(null);
|
||||
const keyFeedbackTimer = useRef(null);
|
||||
const previewVidRef = useRef(null);
|
||||
|
||||
const assetId = content?.asset_id;
|
||||
const storageProvider = content?.storage_provider;
|
||||
const poster = content?.thumbnail_url ?? undefined;
|
||||
|
||||
// ── Resolve stream URL → set as video src directly ───────────────────────
|
||||
//
|
||||
// Previously we fetched all bytes into a Blob and used URL.createObjectURL().
|
||||
// That blob URL could be opened in a new tab and saved with "Save Video As...".
|
||||
// Now we set the stream URL directly as <video src> — no blob is ever created.
|
||||
// The backend blocks direct browser navigation (Sec-Fetch-Mode: navigate → 401)
|
||||
// and the token is IP-bound, so sharing the URL is ineffective.
|
||||
useEffect(() => {
|
||||
if (!assetId) return;
|
||||
|
||||
setBlobUrl(null);
|
||||
setFetchError(false);
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
setCurrentTime(0);
|
||||
setTotalDuration(0);
|
||||
setOverlayVisible(true);
|
||||
setSettingsOpen(false);
|
||||
setEnded(false);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
setFetchLoading(true);
|
||||
try {
|
||||
// Chibisafe — use raw URL directly
|
||||
if (storageProvider === "chibisafe") {
|
||||
const raw = content?.url;
|
||||
if (!raw) throw new Error("No URL in content");
|
||||
if (!cancelled) setBlobUrl(raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// S3 — get token then stream directly; no blob download
|
||||
const { data } = await api.post("/client/media/token", { asset_id: assetId });
|
||||
if (cancelled) return;
|
||||
const { token } = data?.data ?? {};
|
||||
if (!token) throw new Error("No token returned");
|
||||
|
||||
if (!cancelled) setBlobUrl(`${API_BASE}/client/media/stream/${token}`);
|
||||
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
console.error("[VideoBlock] load failed", err);
|
||||
setFetchError(true);
|
||||
} finally {
|
||||
if (!cancelled) setFetchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [assetId, storageProvider]);
|
||||
|
||||
// ── Video events ──────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const v = vidRef.current;
|
||||
if (!v || !blobUrl) return;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setCurrentTime(v.currentTime);
|
||||
if (v.duration) setProgress((v.currentTime / v.duration) * 100);
|
||||
};
|
||||
const onLoaded = () => setTotalDuration(v.duration);
|
||||
const onEnded = () => { setPlaying(false); setOverlayVisible(false); setEnded(true); };
|
||||
const onWaiting = () => setBuffering(true);
|
||||
const onCanPlay = () => setBuffering(false);
|
||||
const onProgress = () => {
|
||||
if (v.buffered.length && v.duration) {
|
||||
setBuffered((v.buffered.end(v.buffered.length - 1) / v.duration) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
v.addEventListener("waiting", onWaiting);
|
||||
v.addEventListener("canplay", onCanPlay);
|
||||
v.addEventListener("timeupdate", onTimeUpdate);
|
||||
v.addEventListener("loadedmetadata", onLoaded);
|
||||
v.addEventListener("ended", onEnded);
|
||||
v.addEventListener("progress", onProgress);
|
||||
|
||||
if (v.readyState >= 1 && v.duration) setTotalDuration(v.duration);
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("waiting", onWaiting);
|
||||
v.removeEventListener("canplay", onCanPlay);
|
||||
v.removeEventListener("timeupdate", onTimeUpdate);
|
||||
v.removeEventListener("loadedmetadata", onLoaded);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
v.removeEventListener("progress", onProgress);
|
||||
};
|
||||
}, [blobUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
v.playbackRate = speed === "Normal" ? 1 : parseFloat(speed);
|
||||
}, [speed]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
}, []);
|
||||
|
||||
const resetHideTimer = useCallback(() => {
|
||||
setControlsVisible(true);
|
||||
clearTimeout(hideTimer.current);
|
||||
if (playing) {
|
||||
hideTimer.current = setTimeout(() => {
|
||||
setControlsVisible(false);
|
||||
setSettingsOpen(false);
|
||||
}, 3000);
|
||||
}
|
||||
}, [playing]);
|
||||
|
||||
useEffect(() => {
|
||||
resetHideTimer();
|
||||
return () => clearTimeout(hideTimer.current);
|
||||
}, [playing, resetHideTimer]);
|
||||
|
||||
const showFeedback = useCallback((icon, label) => {
|
||||
setKeyFeedback((prev) => ({ icon, label, key: (prev?.key ?? 0) + 1 }));
|
||||
clearTimeout(keyFeedbackTimer.current);
|
||||
keyFeedbackTimer.current = setTimeout(() => setKeyFeedback(null), 800);
|
||||
}, []);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) { v.play(); setPlaying(true); setOverlayVisible(false); setEnded(false); }
|
||||
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
|
||||
}, []);
|
||||
|
||||
const restart = () => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
setPlaying(false);
|
||||
setOverlayVisible(true);
|
||||
};
|
||||
|
||||
const handleSeek = (e) => {
|
||||
const v = vidRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = (parseFloat(e.target.value) / 100) * v.duration;
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
setVolume(val);
|
||||
if (vidRef.current) { vidRef.current.volume = val; vidRef.current.muted = val === 0; }
|
||||
setMuted(val === 0);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
v.muted = !v.muted;
|
||||
setMuted(v.muted);
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
const el = wrapRef.current;
|
||||
if (!el) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else el.requestFullscreen?.();
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.target.tagName === "INPUT") return;
|
||||
switch (e.key) {
|
||||
case " ": case "k":
|
||||
e.preventDefault();
|
||||
togglePlay();
|
||||
showFeedback(playing ? <Pause className="size-7 text-white" /> : <Play className="size-7 text-white" />, playing ? "Pause" : "Play");
|
||||
resetHideTimer();
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (vidRef.current) vidRef.current.currentTime = Math.min(vidRef.current.currentTime + 5, vidRef.current.duration);
|
||||
showFeedback(<SkipForward className="size-7 text-white" />, "+5s");
|
||||
resetHideTimer();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
e.preventDefault();
|
||||
if (vidRef.current) vidRef.current.currentTime = Math.max(vidRef.current.currentTime - 5, 0);
|
||||
showFeedback(<SkipBack className="size-7 text-white" />, "-5s");
|
||||
resetHideTimer();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (vidRef.current) {
|
||||
const nv = Math.min(volume + 0.1, 1);
|
||||
vidRef.current.volume = nv; setVolume(nv); setMuted(false);
|
||||
showFeedback(<Volume2 className="size-7 text-white" />, `${Math.round(nv * 100)}%`);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (vidRef.current) {
|
||||
const nv = Math.max(volume - 0.1, 0);
|
||||
vidRef.current.volume = nv; setVolume(nv); setMuted(nv === 0);
|
||||
showFeedback(<Volume1 className="size-7 text-white" />, `${Math.round(nv * 100)}%`);
|
||||
}
|
||||
break;
|
||||
case "m":
|
||||
e.preventDefault();
|
||||
toggleMute();
|
||||
showFeedback(muted ? <Volume2 className="size-7 text-white" /> : <VolumeX className="size-7 text-white" />, muted ? "Unmuted" : "Muted");
|
||||
break;
|
||||
case "f":
|
||||
e.preventDefault();
|
||||
toggleFullscreen();
|
||||
showFeedback(isFullscreen ? <Minimize2 className="size-7 text-white" /> : <Maximize2 className="size-7 text-white" />, isFullscreen ? "Exit fullscreen" : "Fullscreen");
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback]);
|
||||
|
||||
// ── States ────────────────────────────────────────────────────────────────
|
||||
|
||||
if (!assetId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<VideoIcon className="h-4 w-4" /> No video
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center aspect-video rounded-lg bg-black/90">
|
||||
<div className="w-10 h-10 rounded-full border-4 border-white/20 border-t-white animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError || !blobUrl) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center aspect-video rounded-lg bg-black/80 gap-2">
|
||||
<VideoIcon className="h-8 w-8 text-white/30" />
|
||||
<p className="text-sm text-white/50">Video unavailable.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={400}>
|
||||
<div
|
||||
ref={wrapRef}
|
||||
tabIndex={0}
|
||||
className="relative w-full rounded-lg overflow-hidden bg-black select-none outline-none"
|
||||
style={{ aspectRatio: isFullscreen ? undefined : "16/9" }}
|
||||
onMouseMove={resetHideTimer}
|
||||
onMouseLeave={() => { if (playing) setControlsVisible(false); }}
|
||||
onClick={() => { togglePlay(); resetHideTimer(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
>
|
||||
<video
|
||||
ref={vidRef}
|
||||
src={blobUrl}
|
||||
poster={poster}
|
||||
preload="auto"
|
||||
playsInline
|
||||
controlsList="nodownload nofullscreen noremoteplayback"
|
||||
disablePictureInPicture
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className={`w-full h-full ${isFullscreen ? "object-contain" : "object-cover"}`}
|
||||
/>
|
||||
|
||||
{/* Centre overlay */}
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 pointer-events-none ${overlayVisible ? "opacity-100" : "opacity-0"}`}
|
||||
style={{ background: "rgba(0,0,0,0.3)" }}
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center border border-white/25">
|
||||
{playing ? <Pause className="size-6 text-white" /> : <Play className="size-6 text-white ml-0.5" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buffering spinner */}
|
||||
{buffering && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="w-12 h-12 rounded-full border-4 border-white/20 border-t-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyboard feedback */}
|
||||
{keyFeedback && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div
|
||||
key={keyFeedback.key}
|
||||
className="flex flex-col items-center gap-1 px-5 py-3 rounded-2xl border border-white/10"
|
||||
style={{ background: "rgba(0,0,0,0.65)", backdropFilter: "blur(12px)", animation: "fadeInOut 0.8s ease forwards" }}
|
||||
>
|
||||
<span className="leading-none">{keyFeedback.icon}</span>
|
||||
<span className="text-white text-sm font-medium tracking-wide">{keyFeedback.label}</span>
|
||||
</div>
|
||||
<style>{`@keyframes fadeInOut{0%{opacity:0;transform:scale(.85)}20%{opacity:1;transform:scale(1)}70%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(.95)}}`}</style>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End overlay */}
|
||||
{ended && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 pointer-events-none" style={{ background: "rgba(0,0,0,0.55)" }}>
|
||||
<button
|
||||
type="button" aria-label="Replay"
|
||||
className="pointer-events-auto w-16 h-16 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm border border-white/25 flex items-center justify-center transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const v = vidRef.current;
|
||||
if (!v) return;
|
||||
v.currentTime = 0; v.play();
|
||||
setPlaying(true); setEnded(false); setOverlayVisible(false);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="size-7 text-white" />
|
||||
</button>
|
||||
<span className="text-white/70 text-sm">Replay</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings panel */}
|
||||
{settingsOpen && (
|
||||
<SettingsPanel speed={speed} onSpeed={setSpeed} onClose={() => setSettingsOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* Controls bar */}
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 transition-opacity duration-300 ${controlsVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ background: "linear-gradient(to top, rgba(0,0,0,0.98) 0%, rgba(0,0,0,0.4) 80%, transparent 100%)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-3 pb-4">
|
||||
<div
|
||||
className="relative lg:h-2 xs:h-1 group cursor-pointer"
|
||||
onMouseMove={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1);
|
||||
const time = pct * (vidRef.current?.duration ?? 0);
|
||||
setHoverProgress({ x: e.clientX - rect.left, pct: pct * 100, time });
|
||||
if (previewVidRef.current && isFinite(time) && time >= 0) {
|
||||
previewVidRef.current.currentTime = time;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setHoverProgress(null)}
|
||||
>
|
||||
<div className="absolute inset-0 bg-white/25 rounded-full" />
|
||||
<div className="absolute inset-y-0 left-0 bg-white/40 rounded-full transition-[width] duration-300" style={{ width: `${buffered}%` }} />
|
||||
<div className="absolute inset-y-0 left-0 bg-white rounded-full" style={{ width: `${progress}%` }} />
|
||||
<div className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow-md -ml-1.5 opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%` }} />
|
||||
<input type="range" min="0" max="100" step="0.1" value={progress} onChange={handleSeek} aria-label="Seek" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
|
||||
{/* Scrubber preview — also uses blob URL */}
|
||||
{hoverProgress && (
|
||||
<div
|
||||
className="hidden lg:flex absolute bottom-5 flex-col items-center pointer-events-none z-30"
|
||||
style={{ left: `${hoverProgress.x}px`, transform: "translateX(-50%)" }}
|
||||
>
|
||||
<div className="rounded-md overflow-hidden border border-white/20 shadow-xl" style={{ width: 160, height: 90 }}>
|
||||
<video ref={previewVidRef} src={blobUrl} preload="auto" muted playsInline disablePictureInPicture onContextMenu={(e) => e.preventDefault()} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<span className="text-white text-xs mt-1 font-medium tabular-nums drop-shadow">{fmtTime(hoverProgress.time)}</span>
|
||||
<div className="w-2 h-2 bg-black/60 rotate-45 -mt-1 border-r border-b border-white/20" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-2.5 pb-3">
|
||||
<CtrlBtn label={playing ? "Pause" : "Play"} onClick={togglePlay}>
|
||||
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
|
||||
</CtrlBtn>
|
||||
<CtrlBtn label="Restart" onClick={restart}>
|
||||
<SkipBack className="size-4" />
|
||||
</CtrlBtn>
|
||||
|
||||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||||
{volumePanelOpen && (
|
||||
<div
|
||||
className="lg:hidden absolute bottom-9 left-1/2 -translate-x-1/2 z-30 rounded-xl border border-white/10 px-4 py-3 flex flex-col items-center gap-2"
|
||||
style={{ background: "rgba(18,18,18,0.96)", backdropFilter: "blur(16px)" }}
|
||||
>
|
||||
<span className="text-[10px] text-white/40 uppercase tracking-widest">Volume</span>
|
||||
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolumeChange} aria-label="Volume" className="accent-white cursor-pointer" style={{ writingMode: "vertical-lr", direction: "rtl", height: "80px", width: "auto" }} />
|
||||
<span className="text-xs text-white/50 tabular-nums">{muted ? "0" : Math.round(volume * 100)}%</span>
|
||||
</div>
|
||||
)}
|
||||
<CtrlBtn
|
||||
label={muted ? "Unmute" : "Mute"}
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 1024) setVolumePanelOpen((o) => !o);
|
||||
else toggleMute();
|
||||
}}
|
||||
>
|
||||
{muted ? <VolumeX className="size-4 sm:size-5" /> : <Volume2 className="size-4 sm:size-5" />}
|
||||
</CtrlBtn>
|
||||
</div>
|
||||
|
||||
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolumeChange} aria-label="Volume" className="hidden lg:block w-16 accent-white cursor-pointer" onClick={(e) => e.stopPropagation()} />
|
||||
|
||||
<span className="text-white/60 text-sm tabular-nums ml-1.5">
|
||||
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{speed !== "Normal" && (
|
||||
<span className="text-sm text-white/60 bg-white/10 px-1.5 py-0.5 rounded-sm font-mono">{speed}x</span>
|
||||
)}
|
||||
<CtrlBtn label="Settings" onClick={(e) => { e.stopPropagation(); setSettingsOpen((o) => !o); }} className={settingsOpen ? "text-white" : ""}>
|
||||
<Settings className={`size-5 transition-transform duration-300 ${settingsOpen ? "rotate-45" : ""}`} />
|
||||
</CtrlBtn>
|
||||
<CtrlBtn label={isFullscreen ? "Exit fullscreen" : "Fullscreen"} onClick={toggleFullscreen}>
|
||||
{isFullscreen ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
|
||||
</CtrlBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { VideoIcon } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AssetPickerSheet } from "../AssetPickerSheet";
|
||||
|
||||
export function VideoBlock({ content, onUpdate }) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const thumb = content.thumbnail_url ?? null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label>Video</Label>
|
||||
|
||||
{content.url ? (
|
||||
<div
|
||||
className="relative rounded-lg overflow-hidden cursor-pointer group"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
{thumb ? (
|
||||
<img
|
||||
src={thumb}
|
||||
alt="Video thumbnail"
|
||||
className="w-full aspect-video object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-video bg-muted flex items-center justify-center">
|
||||
<VideoIcon className="h-10 w-10 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<p className="text-white text-sm font-medium">Change Video</p>
|
||||
</div>
|
||||
{/* Play icon overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="h-12 w-12 rounded-full bg-black/50 flex items-center justify-center">
|
||||
<VideoIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="w-full aspect-video rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<VideoIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">Click to select a video</p>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="video"
|
||||
onSelect={(asset) => onUpdate({
|
||||
...content,
|
||||
asset_id: asset.asset_id,
|
||||
url: asset.file_url,
|
||||
thumbnail_url: asset.thumbnail_url ?? null,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -66,14 +66,14 @@ const AppBreadcrumb = ({ items = [] }) => {
|
||||
<span key={index} className="flex items-center gap-1.5">
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage className="flex items-center gap-2 max-w-[200px] truncate">
|
||||
<BreadcrumbPage className="flex items-center gap-2 max-w-[300px] truncate">
|
||||
{item.icon}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<div
|
||||
className="flex items-center gap-2 select-none cursor-pointer max-w-[200px]"
|
||||
className="flex items-center gap-2 select-none cursor-pointer max-w-[300px]"
|
||||
onClick={(e) => {
|
||||
if (item.onClick) {
|
||||
item.onClick(e, navigate);
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState } from "react";
|
||||
import { Bell, Trophy, BookOpen, Star, CheckCircle, Copy, Check, Megaphone } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TYPE_ICON = {
|
||||
achievement: Trophy,
|
||||
course: BookOpen,
|
||||
milestone: Star,
|
||||
task: CheckCircle,
|
||||
announcement: Megaphone,
|
||||
};
|
||||
|
||||
function NotificationIcon({ type, className }) {
|
||||
const Icon = TYPE_ICON[type] ?? Bell;
|
||||
return <Icon className={cn("shrink-0", className)} />;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
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 [selected, setSelected] = useState(null);
|
||||
const [copiedCode, setCopiedCode] = useState(false);
|
||||
|
||||
async function handleCopyCode(code) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopiedCode(true);
|
||||
setTimeout(() => setCopiedCode(false), 2000);
|
||||
}
|
||||
|
||||
function handleOpen(open) {
|
||||
if (open) fetchNotifications();
|
||||
}
|
||||
|
||||
function handleClickNotification(n) {
|
||||
if (!n.seen) markSeen(n.notification_id);
|
||||
setSelected(n);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover onOpenChange={handleOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="relative">
|
||||
<Bell className="h-4 w-4" />
|
||||
{unseenCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white leading-none">
|
||||
{unseenCount > 99 ? "99+" : unseenCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm font-semibold">Notifications</span>
|
||||
{unseenCount > 0 && (
|
||||
<button
|
||||
onClick={markAllSeen}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScrollArea className="h-80">
|
||||
{loading && notifications.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">Loading...</p>
|
||||
) : notifications.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">No notifications yet.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{notifications.map((n, i) => (
|
||||
<li key={n.notification_id}>
|
||||
<button
|
||||
onClick={() => handleClickNotification(n)}
|
||||
className={cn(
|
||||
"w-full text-left px-4 py-3 hover:bg-muted/50 transition-colors",
|
||||
!n.seen && "bg-blue-50 dark:bg-blue-950/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
<NotificationIcon type={n.type} className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!n.seen && (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-500" />
|
||||
)}
|
||||
<p className="text-xs font-medium truncate">{n.title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{n.message}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{timeAgo(n.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{i < notifications.length - 1 && <Separator />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Detail dialog — rendered outside the Popover so it isn't clipped */}
|
||||
<Dialog open={!!selected} onOpenChange={(open) => { if (!open) { setSelected(null); setCopiedCode(false); } }}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted">
|
||||
<NotificationIcon
|
||||
type={selected?.type}
|
||||
className="h-5 w-5 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<DialogTitle className="leading-snug">{selected?.title}</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-sm text-foreground/80 leading-relaxed">
|
||||
{selected?.message}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
{selected?.data?.groupCode && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Group Code
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-1 font-mono text-sm bg-muted rounded-lg px-3 py-2 truncate">
|
||||
{selected.data.groupCode}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="shrink-0 gap-1.5"
|
||||
onClick={() => handleCopyCode(selected.data.groupCode)}
|
||||
>
|
||||
{copiedCode
|
||||
? <><Check className="size-3.5" /> Copied</>
|
||||
: <><Copy className="size-3.5" /> Copy</>
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Share this code with others so they can join your group.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="capitalize">{selected?.type}</span>
|
||||
<span>{selected ? formatDate(selected.createdAt) : ""}</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from "react";
|
||||
import { Bell, AlertCircle, UserPlus, Megaphone } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TYPE_ICON = {
|
||||
task_overdue: AlertCircle,
|
||||
user_registration: UserPlus,
|
||||
announcement: Megaphone,
|
||||
};
|
||||
|
||||
function NotificationIcon({ type, className }) {
|
||||
const Icon = TYPE_ICON[type] ?? Bell;
|
||||
return <Icon className={cn("shrink-0", className)} />;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const m = Math.floor(diff / 60_000);
|
||||
if (m < 1) return "just now";
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
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 [selected, setSelected] = useState(null);
|
||||
|
||||
function handleOpen(open) {
|
||||
if (open) fetchNotifications();
|
||||
}
|
||||
|
||||
function handleClickNotification(n) {
|
||||
if (!n.seen) markSeen(n.notification_id);
|
||||
setSelected(n);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover onOpenChange={handleOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
{unseenCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white leading-none">
|
||||
{unseenCount > 99 ? "99+" : unseenCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<span className="text-sm font-semibold">Notifications</span>
|
||||
{unseenCount > 0 && (
|
||||
<button
|
||||
onClick={markAllSeen}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Mark all as read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScrollArea className="h-80">
|
||||
{loading && notifications.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">Loading...</p>
|
||||
) : notifications.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground">No notifications yet.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{notifications.map((n, i) => (
|
||||
<li key={n.notification_id}>
|
||||
<button
|
||||
onClick={() => handleClickNotification(n)}
|
||||
className={cn(
|
||||
"w-full text-left px-4 py-3 hover:bg-muted/50 transition-colors",
|
||||
!n.seen && "bg-blue-50 dark:bg-blue-950/20"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
<NotificationIcon type={n.type} className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!n.seen && (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-500" />
|
||||
)}
|
||||
<p className="text-xs font-medium truncate">{n.title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{n.message}</p>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{timeAgo(n.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{i < notifications.length - 1 && <Separator />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Detail dialog — outside Popover so it isn't clipped */}
|
||||
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted">
|
||||
<NotificationIcon
|
||||
type={selected?.type}
|
||||
className="h-5 w-5 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<DialogTitle className="leading-snug">{selected?.title}</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-sm text-foreground/80 leading-relaxed">
|
||||
{selected?.message}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useState } from 'react'
|
||||
import { Camera, Phone, MapPin, Shield, Trophy, Activity, Plus, Trash2, Pencil, Home, Building2, Edit2, Check, X } from 'lucide-react'
|
||||
import { useProfile } from '@/contexts/ProfileProvider'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Camera, Loader2, Phone, MapPin, Shield, Trophy, Activity, Plus, Trash2, Pencil, Home, Building2, Edit2, Check, X } from 'lucide-react'
|
||||
import AvatarUploadDialog from '@/components/generic/AvatarUploadDialog'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -10,14 +12,14 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
|
||||
import { MOCK_ACTIVITIES, MOCK_ACHIEVEMENTS, ROLE_CONFIG, ACTIVITY_VARIANTS, AVATAR_COLORS } from '@/data/profile.data'
|
||||
import { ROLE_CONFIG, AVATAR_COLORS } from '@/data/profile.data'
|
||||
|
||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||
const addressSchema = z.object({
|
||||
@@ -52,7 +54,7 @@ function AddressDialog({ open, onClose, initial, onSave }) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{initial ? 'Edit address' : 'Add address'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -106,8 +108,10 @@ function AddressDialog({ open, onClose, initial, onSave }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">{initial ? 'Save changes' : 'Add address'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -130,7 +134,7 @@ function PhoneDialog({ open, onClose, initial, onSave }) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{initial ? 'Edit phone number' : 'Add phone number'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -165,8 +169,10 @@ function PhoneDialog({ open, onClose, initial, onSave }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="pt-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit">{initial ? 'Save changes' : 'Add number'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -203,9 +209,18 @@ function DeleteConfirm({ open, onClose, onConfirm, label }) {
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
export default function ProfilePage() {
|
||||
const { user } = useAuth()
|
||||
const {
|
||||
profile, getProfile,
|
||||
updateProfile, profileLoading,
|
||||
uploadAvatar, deleteAvatar, avatarLoading,
|
||||
} = useProfile()
|
||||
|
||||
const isClient = user?.acc_type === 'client'
|
||||
const role = ROLE_CONFIG[user?.acc_type] ?? { label: user?.acc_type, variant: 'outline' }
|
||||
const info = user?.personal_info
|
||||
// Use fresh profile data; fall back to auth context while loading
|
||||
const info = profile?.personal_info ?? user?.personal_info
|
||||
|
||||
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false)
|
||||
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [addresses, setAddresses] = useState(info?.addresses ?? [])
|
||||
@@ -218,9 +233,25 @@ export default function ProfilePage() {
|
||||
|
||||
const fullName = info?.name?.full_name ?? user?.email ?? 'User'
|
||||
const initials = ((info?.name?.given_name?.[0] ?? '') + (info?.name?.last_name?.[0] ?? '')).toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U'
|
||||
const avatarUrl = info?.avatar ?? null
|
||||
const avatarUrl = info?.avatar?.url ?? null
|
||||
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
|
||||
|
||||
|
||||
// Load fresh profile data on mount
|
||||
useEffect(() => { getProfile(); }, [])
|
||||
|
||||
// Sync phones/addresses when profile arrives from API
|
||||
useEffect(() => {
|
||||
if (!profile?.personal_info) return
|
||||
setAddresses(profile.personal_info.addresses ?? [])
|
||||
setPhones(profile.personal_info.phone_number ?? [])
|
||||
}, [profile])
|
||||
|
||||
// Save profile changes (phones + addresses)
|
||||
const handleSave = async () => {
|
||||
const result = await updateProfile({ phone_number: phones, addresses })
|
||||
if (result?.success) setEditing(false)
|
||||
}
|
||||
|
||||
// ── Address handlers ──
|
||||
const handleSaveAddress = (data) => {
|
||||
if (addrDialog.index !== null) {
|
||||
@@ -265,7 +296,10 @@ export default function ProfilePage() {
|
||||
</Avatar>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button className="absolute -bottom-1 -right-1 p-1.5 rounded-full bg-primary text-primary-foreground shadow hover:opacity-80 transition-opacity">
|
||||
<button
|
||||
className="absolute -bottom-1 -right-1 p-1.5 rounded-full bg-primary text-primary-foreground shadow hover:opacity-80 transition-opacity"
|
||||
onClick={() => setAvatarDialogOpen(true)}
|
||||
>
|
||||
<Camera size={11} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -278,9 +312,11 @@ export default function ProfilePage() {
|
||||
<h1 className="text-xl font-semibold">{fullName}</h1>
|
||||
<p className="text-sm text-muted-foreground">{user?.email}</p>
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<Badge className="bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300">
|
||||
{info.occupation}
|
||||
</Badge>
|
||||
{info?.occupation && (
|
||||
<Badge className="bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300">
|
||||
{info.occupation}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={role.variant}>{role.label}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,8 +325,12 @@ export default function ProfilePage() {
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setEditing(false)} className="gap-1.5">
|
||||
<Check size={13} /> Save profile
|
||||
<Button size="sm" onClick={handleSave} disabled={profileLoading} className="gap-1.5">
|
||||
{profileLoading
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <Check size={13} />
|
||||
}
|
||||
Save profile
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(false)} className="gap-1.5">
|
||||
<X size={13} /> Cancel
|
||||
@@ -421,19 +461,7 @@ export default function ProfilePage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Separator className="mb-1" />
|
||||
<div className="divide-y">
|
||||
{MOCK_ACTIVITIES.map((item) => (
|
||||
<div key={item.id} className="flex items-start gap-3 py-3">
|
||||
<Badge variant={ACTIVITY_VARIANTS[item.type]} className="mt-0.5 capitalize shrink-0">
|
||||
{item.type}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-snug">{item.label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{item.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground py-2">No recent activities.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -442,23 +470,11 @@ export default function ProfilePage() {
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Trophy size={14} className="text-muted-foreground" />
|
||||
Achievements
|
||||
<Badge variant="secondary" className="ml-auto">{MOCK_ACHIEVEMENTS.length} earned</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Separator className="mb-3" />
|
||||
<div className="space-y-2">
|
||||
{MOCK_ACHIEVEMENTS.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors">
|
||||
<span className="text-2xl">{item.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold">{item.title}</p>
|
||||
<p className="text-xs text-muted-foreground">{item.desc}</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">{item.date}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground py-2">No achievements yet.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
@@ -505,6 +521,16 @@ export default function ProfilePage() {
|
||||
onConfirm={handleDeletePhone}
|
||||
label="phone number"
|
||||
/>
|
||||
|
||||
<AvatarUploadDialog
|
||||
open={avatarDialogOpen}
|
||||
onClose={() => setAvatarDialogOpen(false)}
|
||||
currentAvatarUrl={avatarUrl ?? ''}
|
||||
initials={initials}
|
||||
onUpload={uploadAvatar}
|
||||
onDelete={deleteAvatar}
|
||||
loading={avatarLoading}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { VisuallyHidden } from "radix-ui";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerClose,
|
||||
} from "@/components/ui/drawer";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useMediaQuery
|
||||
// ---------------------------------------------------------------------------
|
||||
function useMediaQuery(query) {
|
||||
const [matches, setMatches] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
setMatches(mql.matches);
|
||||
const handler = (e) => setMatches(e.matches);
|
||||
mql.addEventListener("change", handler);
|
||||
return () => mql.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ResponsiveModal
|
||||
//
|
||||
// Props:
|
||||
// open boolean
|
||||
// onOpenChange (open: boolean) => void
|
||||
//
|
||||
// title ReactNode? — rendered as DialogTitle / DrawerTitle
|
||||
// description ReactNode? — rendered as DialogDescription / DrawerDescription
|
||||
// footer ReactNode? — rendered as DialogFooter / DrawerFooter
|
||||
// children ReactNode? — modal body
|
||||
// onAction - fetch api call
|
||||
//
|
||||
// dialogContentProps object? — forwarded to <DialogContent>
|
||||
// drawerContentProps object? — forwarded to <DrawerContent>
|
||||
// drawerDirection "top"|"bottom"|"left"|"right" (default "bottom")
|
||||
// hideDrawerClose boolean? — hide the default Close button in drawer
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
// Usage:
|
||||
//
|
||||
// <ResponsiveModal
|
||||
// open={open}
|
||||
// onOpenChange={setOpen}
|
||||
// title="Are you absolutely sure?"
|
||||
// description="This action cannot be undone."
|
||||
// footer={
|
||||
// <>
|
||||
// <button onClick={() => setOpen(false)}>Cancel</button>
|
||||
// <button>Confirm</button>
|
||||
// </>
|
||||
// }
|
||||
// >
|
||||
// <MyForm />
|
||||
// </ResponsiveModal>
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
export function ResponsiveModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
children,
|
||||
onAction,
|
||||
dialogContentProps = {},
|
||||
drawerContentProps = {},
|
||||
drawerDirection = "bottom",
|
||||
hideDrawerClose = false,
|
||||
}) {
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
// ── Desktop → Dialog ──────────────────────────────────────────────────
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
{...dialogContentProps}
|
||||
className={cn("sm:max-w-lg", dialogContentProps.className)}
|
||||
>
|
||||
{title || description ? (
|
||||
<DialogHeader>
|
||||
{title && <DialogTitle>{title}</DialogTitle>}
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
) : (
|
||||
<VisuallyHidden.Root>
|
||||
<DialogTitle />
|
||||
<DialogDescription />
|
||||
</VisuallyHidden.Root>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{(footer || onAction) && (
|
||||
<DialogFooter>
|
||||
{footer}
|
||||
{onAction && (
|
||||
<Button onClick={onAction}>Confirm</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Mobile → Drawer ───────────────────────────────────────────────────
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange} direction={drawerDirection}>
|
||||
<DrawerContent
|
||||
{...drawerContentProps}
|
||||
className={cn("max-h-[85svh]", drawerContentProps.className)}
|
||||
>
|
||||
{title || description ? (
|
||||
<DrawerHeader>
|
||||
{title && <DrawerTitle>{title}</DrawerTitle>}
|
||||
{description ? (
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
) : (
|
||||
<VisuallyHidden.Root>
|
||||
<DrawerDescription />
|
||||
</VisuallyHidden.Root>
|
||||
)}
|
||||
</DrawerHeader>
|
||||
) : (
|
||||
<VisuallyHidden.Root>
|
||||
<DrawerTitle />
|
||||
<DrawerDescription />
|
||||
</VisuallyHidden.Root>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto px-4 pb-4">{children}</div>
|
||||
|
||||
{(footer || onAction || !hideDrawerClose) && (
|
||||
<DrawerFooter className="pt-2">
|
||||
{footer}
|
||||
{onAction && (
|
||||
<button
|
||||
onClick={onAction}
|
||||
className="w-full rounded-md bg-primary px-4 py-3 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
)}
|
||||
{!hideDrawerClose && (
|
||||
<DrawerClose asChild>
|
||||
<button className="mt-1 w-full rounded-md border border-input bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground">
|
||||
Close
|
||||
</button>
|
||||
</DrawerClose>
|
||||
)}
|
||||
</DrawerFooter>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export { useMediaQuery };
|
||||
export default ResponsiveModal;
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// USAGE EXAMPLES
|
||||
// ===========================================================================
|
||||
//
|
||||
// const [open, setOpen] = useState(false);
|
||||
//
|
||||
//
|
||||
// ── 1. Full example ───────────────────────────────────────────────────────
|
||||
//
|
||||
// <ResponsiveModal
|
||||
// open={open}
|
||||
// onOpenChange={setOpen}
|
||||
// title="Are you absolutely sure?"
|
||||
// description="This action cannot be undone. This will permanently delete
|
||||
// your account and remove your data from our servers."
|
||||
// footer={
|
||||
// <>
|
||||
// <Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
// <Button>Continue</Button>
|
||||
// </>
|
||||
// }
|
||||
// >
|
||||
// <MyForm />
|
||||
// </ResponsiveModal>
|
||||
//
|
||||
//
|
||||
// ── 2. Title only (no description) ───────────────────────────────────────
|
||||
//
|
||||
// <ResponsiveModal
|
||||
// open={open}
|
||||
// onOpenChange={setOpen}
|
||||
// title="Confirm Delete"
|
||||
// footer={<Button>Delete</Button>}
|
||||
// >
|
||||
// <p>Are you sure?</p>
|
||||
// </ResponsiveModal>
|
||||
//
|
||||
//
|
||||
// ── 3. No header ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// <ResponsiveModal open={open} onOpenChange={setOpen}>
|
||||
// <p>Body only.</p>
|
||||
// </ResponsiveModal>
|
||||
//
|
||||
//
|
||||
// ── 4. Custom width ───────────────────────────────────────────────────────
|
||||
//
|
||||
// <ResponsiveModal
|
||||
// open={open}
|
||||
// onOpenChange={setOpen}
|
||||
// title="Wide Modal"
|
||||
// dialogContentProps={{ className: "sm:max-w-2xl" }}
|
||||
// >
|
||||
// <BigTable />
|
||||
// </ResponsiveModal>
|
||||
//
|
||||
// ===========================================================================
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* ╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
* ║ ScrollToTop.jsx ║
|
||||
* ╠══════════════════════════════════════════════════════════════════════════════╣
|
||||
* ║ Author : Kenneth Obsequio (@lash0000) ║
|
||||
* ║ Date Created : May 25, 2026 ║
|
||||
* ║ ║
|
||||
* ╠══════════════════════════════════════════════════════════════════════════════╣
|
||||
* ║ Changelog ║
|
||||
* ║ - For every navigate or <Link> redirection. ║
|
||||
* ╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
*/
|
||||
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
@@ -2,33 +2,19 @@ import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useProfile } from '@/contexts/ProfileProvider'
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { AlertDialog, AlertDialogContent, } from '@/components/ui/alert-dialog'
|
||||
|
||||
import { User, Settings, LogOut, X, Sun, Moon, Monitor, Lock, Loader2, Check } from 'lucide-react'
|
||||
import { User, Settings, LogOut, Sun, Moon, Monitor, Loader2, Check } from 'lucide-react'
|
||||
|
||||
import { AVATAR_COLORS } from '@/data/profile.data'
|
||||
|
||||
// ─── Password schema ──────────────────────────────────────────────────────────
|
||||
const passwordSchema = z.object({
|
||||
current_password: z.string().min(1, 'Current password is required'),
|
||||
new_password: z.string().min(8, 'At least 8 characters'),
|
||||
confirm_password: z.string(),
|
||||
}).refine((d) => d.new_password === d.confirm_password, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirm_password'],
|
||||
})
|
||||
|
||||
// ─── Theme Option ─────────────────────────────────────────────────────────────
|
||||
function ThemeOption({ value, label, icon: Icon, active, onClick }) {
|
||||
return (
|
||||
@@ -57,41 +43,20 @@ function ThemeOption({ value, label, icon: Icon, active, onClick }) {
|
||||
function SettingsDialog({ open, onClose }) {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [tab, setTab] = useState('appearance')
|
||||
const [pwSuccess, setPwSuccess] = useState(false)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm({ resolver: zodResolver(passwordSchema) })
|
||||
|
||||
const onPasswordSubmit = async (data) => {
|
||||
// TODO: call your API here
|
||||
// await api.post('/auth/change-password', data)
|
||||
await new Promise((r) => setTimeout(r, 800)) // mock delay
|
||||
setPwSuccess(true)
|
||||
reset()
|
||||
setTimeout(() => setPwSuccess(false), 3000)
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'appearance', label: 'Appearance', icon: Sun },
|
||||
{ id: 'password', label: 'Password', icon: Lock },
|
||||
]
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="p-0 overflow-hidden max-w-2xl gap-0 rounded-2xl">
|
||||
<DialogContent className="p-0 overflow-hidden sm:max-w-2xl gap-0 rounded-2xl">
|
||||
<div className="flex h-[480px]">
|
||||
|
||||
{/* ── Sidebar ── */}
|
||||
<div className="w-52 shrink-0 border-r bg-muted/30 flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<div className="flex items-center px-4 py-3 border-b">
|
||||
<span className="text-sm font-semibold">Settings</span>
|
||||
<Button variant="ghost" size="icon" className="size-7 rounded-md" onClick={onClose}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<nav className="flex-1 p-2 space-y-0.5">
|
||||
{TABS.map((t) => (
|
||||
@@ -135,77 +100,6 @@ function SettingsDialog({ open, onClose }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
{tab === 'password' && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Change password</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Update your password to keep your account secure.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{pwSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400 px-3 py-2 rounded-md border border-green-200 dark:border-green-800">
|
||||
<Check size={14} /> Password updated successfully.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onPasswordSubmit)} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="current_password">Current password</Label>
|
||||
<Input
|
||||
id="current_password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isSubmitting}
|
||||
{...register('current_password')}
|
||||
/>
|
||||
{errors.current_password && (
|
||||
<p className="text-xs text-destructive">{errors.current_password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="new_password">New password</Label>
|
||||
<Input
|
||||
id="new_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isSubmitting}
|
||||
{...register('new_password')}
|
||||
/>
|
||||
{errors.new_password && (
|
||||
<p className="text-xs text-destructive">{errors.new_password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirm_password">Confirm new password</Label>
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
disabled={isSubmitting}
|
||||
{...register('confirm_password')}
|
||||
/>
|
||||
{errors.confirm_password && (
|
||||
<p className="text-xs text-destructive">{errors.confirm_password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="sm" disabled={isSubmitting} className="gap-1.5">
|
||||
{isSubmitting ? (
|
||||
<><Loader2 size={13} className="animate-spin" /> Updating...</>
|
||||
) : (
|
||||
'Update password'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -230,6 +124,7 @@ function SignOutOverlay({ open }) {
|
||||
// ─── Main UserMenu ────────────────────────────────────────────────────────────
|
||||
export default function UserMenu() {
|
||||
const { user, logout } = useAuth()
|
||||
const { avatarUrl } = useProfile()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
@@ -245,7 +140,6 @@ export default function UserMenu() {
|
||||
|
||||
const fullName = given && last ? `${given} ${last}` : user?.email ?? 'User'
|
||||
const shortName = (given || user?.email) ?? 'User'
|
||||
const avatarUrl = user?.personal_info?.avatar ?? null
|
||||
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
|
||||
|
||||
const handleLogout = async () => {
|
||||
|
||||
@@ -42,7 +42,7 @@ function CardTitle({
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
|
||||
@@ -58,7 +58,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-[inherit] data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
@@ -63,10 +63,17 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7",
|
||||
|
||||
// destructive variant
|
||||
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
|
||||
// shared disabled state
|
||||
"data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const AdvertisementsContext = createContext(null);
|
||||
|
||||
export function useAdvertisements() {
|
||||
const ctx = useContext(AdvertisementsContext);
|
||||
if (!ctx) throw new Error("useAdvertisements must be used within an AdvertisementsProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ─── Initial States ────────────────────────────────────────────────────────────
|
||||
|
||||
const PAGINATION_INIT = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalRecords: 0,
|
||||
totalPages: 0,
|
||||
hasPrevPage: false,
|
||||
hasNextPage: false,
|
||||
};
|
||||
|
||||
export function AdvertisementsProvider({ children }) {
|
||||
const [advertisements, setAdvertisements] = useState([]);
|
||||
const [attributes, setAttributes] = useState([]);
|
||||
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
||||
const [selectedAdvertisement, setSelectedAdvertisement] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const request = useCallback(async (fn) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const message = err?.response?.data?.message ?? "Something went wrong.";
|
||||
toast.error(message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── GET /api/admin/advertisements ────────────────────────────────────────
|
||||
const fetchAdvertisements = useCallback(
|
||||
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||
request(async () => {
|
||||
const { data } = await api.get("/admin/advertisements", {
|
||||
params: {
|
||||
page, limit,
|
||||
filters: filters.length ? JSON.stringify(filters) : undefined,
|
||||
sort: sort.length ? JSON.stringify(sort) : undefined,
|
||||
},
|
||||
});
|
||||
const result = data?.data;
|
||||
setAdvertisements(result?.data ?? []);
|
||||
setPagination(result?.pagination ?? PAGINATION_INIT);
|
||||
setAttributes(result.attributes);
|
||||
return data.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET /api/admin/advertisements/:advertisementId ───────────────────────
|
||||
const fetchAdvertisement = useCallback(
|
||||
(advertisementId) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`/admin/advertisements/${advertisementId}`);
|
||||
setSelectedAdvertisement(res.data?.data?.data ?? null);
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET /api/admin/advertisements/archived ────────────────────────────────
|
||||
const fetchArchivedAdvertisements = useCallback(
|
||||
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||
request(async () => {
|
||||
const { data } = await api.get("/admin/advertisements/archived", {
|
||||
params: {
|
||||
page, limit,
|
||||
filters: filters.length ? JSON.stringify(filters) : undefined,
|
||||
sort: sort.length ? JSON.stringify(sort) : undefined,
|
||||
},
|
||||
});
|
||||
const final_data = data?.data;
|
||||
setAdvertisements(final_data?.data ?? []);
|
||||
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
||||
setAttributes(final_data.attributes);
|
||||
return data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── POST /api/admin/advertisements ────────────────────────────────────────
|
||||
const createAdvertisement = useCallback(
|
||||
(fields) =>
|
||||
request(async () => {
|
||||
const res = await api.post("/admin/advertisements", fields);
|
||||
const advertisement = res.data?.data?.data ?? null;
|
||||
if (advertisement) {
|
||||
setAdvertisements((prev) => [advertisement, ...prev]);
|
||||
toast.success("Advertisement created successfully.");
|
||||
}
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── PATCH /api/admin/advertisements/:advertisementId ─────────────────────
|
||||
const updateAdvertisement = useCallback(
|
||||
(advertisementId, fields) =>
|
||||
request(async () => {
|
||||
const res = await api.patch(`/admin/advertisements/${advertisementId}`, fields);
|
||||
const advertisement = res.data?.data?.data ?? null;
|
||||
if (advertisement) {
|
||||
setAdvertisements((prev) => prev.map((a) => (a.advertisement_id === advertisementId ? advertisement : a)));
|
||||
setSelectedAdvertisement(advertisement);
|
||||
toast.success("Advertisement updated successfully.");
|
||||
}
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── DELETE /api/admin/advertisements/:advertisementId ────────────────────
|
||||
const archiveAdvertisement = useCallback(
|
||||
(advertisementId, { deletedBy } = {}) =>
|
||||
request(async () => {
|
||||
const res = await api.delete(`/admin/advertisements/${advertisementId}`, {
|
||||
data: { deletedBy },
|
||||
});
|
||||
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
||||
setSelectedAdvertisement((prev) => (prev?.advertisement_id === advertisementId ? null : prev));
|
||||
toast.success("Advertisement archived.");
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── DELETE /api/admin/advertisements/bulk ─────────────────────────────────
|
||||
const archiveAdvertisements = useCallback(
|
||||
({ ids }, { deletedBy } = {}) =>
|
||||
request(async () => {
|
||||
const res = await api.delete("/admin/advertisements/bulk", {
|
||||
data: { ids, deletedBy },
|
||||
});
|
||||
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
||||
toast.success(`${ids.length} advertisement(s) archived.`);
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── PATCH /api/admin/advertisements/:advertisementId/restore ─────────────
|
||||
const restoreAdvertisement = useCallback(
|
||||
(advertisementId) =>
|
||||
request(async () => {
|
||||
const res = await api.patch(`/admin/advertisements/${advertisementId}/restore`);
|
||||
const advertisement = res.data?.data?.data ?? null;
|
||||
if (advertisement) {
|
||||
setAdvertisements((prev) => prev.filter((a) => a.advertisement_id !== advertisementId));
|
||||
toast.success("Advertisement restored.");
|
||||
}
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── PATCH /api/admin/advertisements/bulk-restore ──────────────────────────
|
||||
const restoreAdvertisements = useCallback(
|
||||
({ ids }) =>
|
||||
request(async () => {
|
||||
const res = await api.patch("/admin/advertisements/bulk-restore", { ids });
|
||||
setAdvertisements((prev) => prev.filter((a) => !ids.includes(a.advertisement_id)));
|
||||
toast.success(`${ids.length} advertisement(s) restored.`);
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET /api/admin/advertisements/field-values ────────────────────────────
|
||||
const fetchAdvertisementFieldValues = useCallback(
|
||||
(field) =>
|
||||
request(async () => {
|
||||
const res = await api.get("/admin/advertisements/field-values", { params: { field } });
|
||||
return res.data?.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
return (
|
||||
<AdvertisementsContext.Provider value={{
|
||||
advertisements,
|
||||
attributes,
|
||||
pagination,
|
||||
selectedAdvertisement,
|
||||
loading,
|
||||
setPagination,
|
||||
setSelectedAdvertisement,
|
||||
fetchAdvertisements,
|
||||
fetchAdvertisement,
|
||||
fetchArchivedAdvertisements,
|
||||
createAdvertisement,
|
||||
updateAdvertisement,
|
||||
archiveAdvertisement,
|
||||
archiveAdvertisements,
|
||||
restoreAdvertisement,
|
||||
restoreAdvertisements,
|
||||
fetchAdvertisementFieldValues
|
||||
}}>
|
||||
{children}
|
||||
</AdvertisementsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const AdminCategoriesContext = createContext(null);
|
||||
|
||||
export function AdminCategoriesProvider({ children }) {
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [category, setCategory] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const wrap = useCallback(async (fn) => {
|
||||
setLoading(true);
|
||||
try { return await fn(); }
|
||||
catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Something went wrong.");
|
||||
return null;
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const fetchCategories = useCallback((archived = false) => wrap(async () => {
|
||||
const { data } = await api.get("/admin/categories", { params: { archived } });
|
||||
setCategories(data.data ?? []);
|
||||
return data.data;
|
||||
}), [wrap]);
|
||||
|
||||
const fetchCategory = useCallback((id) => wrap(async () => {
|
||||
const { data } = await api.get(`/admin/categories/${id}`);
|
||||
setCategory(data.data ?? null);
|
||||
return data.data;
|
||||
}), [wrap]);
|
||||
|
||||
const createCategory = useCallback((payload) => wrap(async () => {
|
||||
const { data } = await api.post("/admin/categories", payload);
|
||||
toast.success("Category created.");
|
||||
return data.data;
|
||||
}), [wrap]);
|
||||
|
||||
const updateCategory = useCallback((id, payload) => wrap(async () => {
|
||||
const { data } = await api.put(`/admin/categories/${id}`, payload);
|
||||
setCategory(data.data ?? null);
|
||||
toast.success("Category updated.");
|
||||
return data.data;
|
||||
}), [wrap]);
|
||||
|
||||
const archiveCategory = useCallback((id) => wrap(async () => {
|
||||
await api.delete(`/admin/categories/${id}`);
|
||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
||||
toast.success("Category archived.");
|
||||
return true;
|
||||
}), [wrap]);
|
||||
|
||||
const restoreCategory = useCallback((id) => wrap(async () => {
|
||||
await api.post(`/admin/categories/${id}/restore`);
|
||||
setCategories((prev) => prev.filter((c) => c.id !== id));
|
||||
toast.success("Category restored.");
|
||||
return true;
|
||||
}), [wrap]);
|
||||
|
||||
return (
|
||||
<AdminCategoriesContext.Provider value={{
|
||||
categories, category, loading,
|
||||
fetchCategories, fetchCategory,
|
||||
createCategory, updateCategory,
|
||||
archiveCategory, restoreCategory,
|
||||
}}>
|
||||
{children}
|
||||
</AdminCategoriesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useCategories = () => useContext(AdminCategoriesContext);
|
||||
@@ -0,0 +1,88 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : AdminCourseReadingProgressContext.jsx
|
||||
* Type : Context / Provider
|
||||
* Description : Admin context for course reading progress.
|
||||
*
|
||||
* fetchCourseReadingProgress(courseId)
|
||||
* → GET /admin/courses/:courseId/reading-progress
|
||||
* → returns one summary entry per user
|
||||
*
|
||||
* fetchUserReadingProgress(courseId, userId)
|
||||
* → GET /admin/courses/:courseId/reading-progress/users/:userId
|
||||
* → returns unit + lesson breakdown for a single user (loaded on expand)
|
||||
* → cached in detailCache so repeated expands don't re-fetch
|
||||
*
|
||||
* Used by: CourseReadingProgressList.jsx (inside ViewCourse admin page)
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import api from '@/utils/api.util';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const AdminCourseReadingProgressContext = createContext(null);
|
||||
|
||||
export function useAdminCourseReadingProgress() {
|
||||
const ctx = useContext(AdminCourseReadingProgressContext);
|
||||
if (!ctx) throw new Error('useAdminCourseReadingProgress must be used within AdminCourseReadingProgressProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function AdminCourseReadingProgressProvider({ children }) {
|
||||
const [progressList, setProgressList] = useState([]);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
|
||||
// { [userId]: unitBreakdown[] } — populated lazily on row expand
|
||||
const [detailCache, setDetailCache] = useState({});
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// ── Summary list ──────────────────────────────────────────────────────────
|
||||
|
||||
const fetchCourseReadingProgress = useCallback(async (courseId) => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/admin/courses/${courseId}/reading-progress`);
|
||||
setProgressList(data.data ?? []);
|
||||
setDetailCache({});
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? 'Could not load reading progress.');
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Per-user detail (lazy, cached) ────────────────────────────────────────
|
||||
|
||||
const fetchUserReadingProgress = useCallback(async (courseId, userId) => {
|
||||
if (detailCache[userId]) return detailCache[userId];
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/admin/courses/${courseId}/reading-progress/users/${userId}`);
|
||||
const breakdown = data.data ?? [];
|
||||
setDetailCache((prev) => ({ ...prev, [userId]: breakdown }));
|
||||
return breakdown;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? 'Could not load user progress.');
|
||||
return null;
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, [detailCache]);
|
||||
|
||||
const resetProgress = useCallback(() => {
|
||||
setProgressList([]);
|
||||
setDetailCache({});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AdminCourseReadingProgressContext.Provider value={{
|
||||
progressList,
|
||||
listLoading,
|
||||
detailCache,
|
||||
detailLoading,
|
||||
fetchCourseReadingProgress,
|
||||
fetchUserReadingProgress,
|
||||
resetProgress,
|
||||
}}>
|
||||
{children}
|
||||
</AdminCourseReadingProgressContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -847,6 +847,62 @@ export function CoursesProvider({ children }) {
|
||||
[request],
|
||||
);
|
||||
|
||||
// =========================================================================
|
||||
// COURSE PRODUCT & CATEGORIES
|
||||
// =========================================================================
|
||||
|
||||
const fetchCourseProduct = useCallback(
|
||||
(courseId) => request(async () => {
|
||||
const { data } = await api.get(`/admin/products/courses/${courseId}/product`);
|
||||
return data.data ?? null;
|
||||
}), [request],
|
||||
);
|
||||
|
||||
const saveCourseProduct = useCallback(
|
||||
(courseId, payload) => request(async () => {
|
||||
const { data } = await api.put(`/admin/products/courses/${courseId}/product`, payload);
|
||||
toast.success("Product listing saved.");
|
||||
return data.data ?? null;
|
||||
}), [request],
|
||||
);
|
||||
|
||||
const removeCourseProduct = useCallback(
|
||||
(courseId) => request(async () => {
|
||||
await api.delete(`/admin/products/courses/${courseId}/product`);
|
||||
toast.success("Product listing removed.");
|
||||
return true;
|
||||
}), [request],
|
||||
);
|
||||
|
||||
const fetchCourseCategories = useCallback(
|
||||
(courseId) => request(async () => {
|
||||
const { data } = await api.get(`/admin/products/courses/${courseId}/categories`);
|
||||
return data.data ?? [];
|
||||
}), [request],
|
||||
);
|
||||
|
||||
const syncCourseCategories = useCallback(
|
||||
(courseId, categoryIds) => request(async () => {
|
||||
const { data } = await api.post(`/admin/products/courses/${courseId}/categories`, { category_ids: categoryIds });
|
||||
toast.success("Categories updated.");
|
||||
return data.data ?? [];
|
||||
}), [request],
|
||||
);
|
||||
|
||||
const fetchInstructors = useCallback(
|
||||
(courseId) => request(async () => {
|
||||
const { data } = await api.get(`${BASE}/${courseId}/instructors`);
|
||||
return data.data ?? [];
|
||||
}), [request],
|
||||
);
|
||||
|
||||
const syncInstructors = useCallback(
|
||||
(courseId, instructors) => request(async () => {
|
||||
await api.put(`${BASE}/${courseId}/instructors`, { instructors });
|
||||
toast.success("Instructors updated.");
|
||||
}), [request],
|
||||
);
|
||||
|
||||
const fetchCourseFieldValues = useCallback(
|
||||
(field) =>
|
||||
request(async () => {
|
||||
@@ -1001,7 +1057,16 @@ export function CoursesProvider({ children }) {
|
||||
// ── fetch field values ─────────────────────────────
|
||||
fetchCourseFieldValues,
|
||||
fetchUnitFieldValues,
|
||||
fetchLessonFieldValues
|
||||
fetchLessonFieldValues,
|
||||
|
||||
// ── course product & categories ────────────────────
|
||||
fetchCourseProduct,
|
||||
saveCourseProduct,
|
||||
removeCourseProduct,
|
||||
fetchCourseCategories,
|
||||
syncCourseCategories,
|
||||
fetchInstructors,
|
||||
syncInstructors,
|
||||
}}>
|
||||
{children}
|
||||
</CoursesContext.Provider>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
const AdminNotificationContext = createContext(null);
|
||||
|
||||
const POLL_INTERVAL = 60_000; // 60 seconds
|
||||
|
||||
export function useAdminNotifications() {
|
||||
const ctx = useContext(AdminNotificationContext);
|
||||
if (!ctx) throw new Error("useAdminNotifications must be used within AdminNotificationProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function AdminNotificationProvider({ children }) {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unseenCount, setUnseenCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const intervalRef = useRef(null);
|
||||
|
||||
const fetchUnseen = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get("/admin/notifications/unseen");
|
||||
setUnseenCount(res.data?.data?.count ?? 0);
|
||||
} catch {
|
||||
// silent — badge simply stays at last known count
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get("/admin/notifications?limit=20");
|
||||
const rows = res.data?.data?.notifications ?? [];
|
||||
setNotifications(rows);
|
||||
setUnseenCount(rows.filter(n => !n.seen).length);
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markSeen = useCallback(async (id) => {
|
||||
try {
|
||||
await api.patch(`/admin/notifications/${id}/seen`);
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.notification_id === id ? { ...n, seen: true } : n)
|
||||
);
|
||||
setUnseenCount(prev => Math.max(0, prev - 1));
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markAllSeen = useCallback(async () => {
|
||||
try {
|
||||
await api.patch("/admin/notifications/seen-all");
|
||||
setNotifications(prev => prev.map(n => ({ ...n, seen: true })));
|
||||
setUnseenCount(0);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load + start polling unseen count
|
||||
useEffect(() => {
|
||||
fetchUnseen();
|
||||
intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL);
|
||||
return () => clearInterval(intervalRef.current);
|
||||
}, [fetchUnseen]);
|
||||
|
||||
return (
|
||||
<AdminNotificationContext.Provider value={{
|
||||
notifications,
|
||||
unseenCount,
|
||||
loading,
|
||||
fetchNotifications,
|
||||
markSeen,
|
||||
markAllSeen,
|
||||
}}>
|
||||
{children}
|
||||
</AdminNotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
* Covers: task lists (list, get, create, update, archive, restore, bulk archive, bulk restore)
|
||||
* tasks (list, get, create, update, archive, restore, bulk archive, bulk restore)
|
||||
* task list groups (list assigned, assign, unassign)
|
||||
* task completions (list, get, list by user, archive, restore, bulk archive, bulk restore)
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import api from '@/utils/api.util';
|
||||
@@ -23,23 +24,33 @@ export function useAdminTask() {
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
export function AdminTaskProvider({ children }) {
|
||||
|
||||
// ── Task List state ───────────────────────────────────────────────────────
|
||||
const [taskLists, setTaskLists] = useState([]);
|
||||
const [taskList, setTaskList] = useState(null);
|
||||
const [taskLists, setTaskLists] = useState([]);
|
||||
const [taskList, setTaskList] = useState(null);
|
||||
|
||||
// ── Task state ────────────────────────────────────────────────────────────
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [task, setTask] = useState(null);
|
||||
const [task, setTask] = useState(null);
|
||||
|
||||
// ── Task List Groups state ────────────────────────────────────────────────
|
||||
const [taskListGroups, setTaskListGroups] = useState([]);
|
||||
|
||||
// ── Shared ────────────────────────────────────────────────────────────────
|
||||
const [attributes, setAttributes] = useState([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 10, totalRecords: 0, totalPages: 1 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
// ── Completion state ──────────────────────────────────────────────────────
|
||||
const [completions, setCompletions] = useState([]);
|
||||
const [completion, setCompletion] = useState(null);
|
||||
|
||||
// ─── Generic request wrapper ──────────────────────────────────────────────
|
||||
// ── Shared (task lists + tasks) ───────────────────────────────────────────
|
||||
const [attributes, setAttributes] = useState([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 10, totalRecords: 0, totalPages: 1 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// ── Completion-specific (separate to avoid conflicts) ─────────────────────
|
||||
const [completionPagination, setCompletionPagination] = useState({ page: 1, limit: 10, totalRecords: 0, totalPages: 1 });
|
||||
const [completionLoading, setCompletionLoading] = useState(false);
|
||||
const [completionAttributes, setCompletionAttributes] = useState([]);
|
||||
|
||||
// ─── Generic request wrappers ─────────────────────────────────────────────
|
||||
const request = useCallback(async (fn) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -53,26 +64,34 @@ export function AdminTaskProvider({ children }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const completionRequest = useCallback(async (fn) => {
|
||||
setCompletionLoading(true);
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
||||
toast.error(message);
|
||||
return null;
|
||||
} finally {
|
||||
setCompletionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TASK LISTS
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────
|
||||
const fetchTaskLists = useCallback(
|
||||
({ page = 1, limit = 10, filters = [], sort = [], archived = false } = {}) =>
|
||||
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||
request(async () => {
|
||||
const res = await api.get(BASE, {
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
page, limit,
|
||||
filters: JSON.stringify(filters),
|
||||
sort: JSON.stringify(sort),
|
||||
archived: archived ? 'true' : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {};
|
||||
|
||||
setTaskLists(data ?? []);
|
||||
setAttributes(attrs ?? []);
|
||||
setPagination(pg ?? { page: 1, limit: 10, totalRecords: 0, totalPages: 1 });
|
||||
@@ -80,37 +99,29 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────
|
||||
// Response now includes a `groups` array on the task list object.
|
||||
const fetchTaskList = useCallback(
|
||||
(taskListId) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/${taskListId}`);
|
||||
const taskListData = res.data?.data ?? null;
|
||||
setTaskList(taskListData);
|
||||
// Sync the groups slice from the embedded payload so consumers
|
||||
// don't have to call fetchTaskListGroups separately after a getOne.
|
||||
if (taskListData?.groups) setTaskListGroups(taskListData.groups);
|
||||
return taskListData;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET ARCHIVED TASK LISTS ──────────────────────────────────────────────
|
||||
const fetchArchivedTaskLists = useCallback(
|
||||
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/archived`, {
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
page, limit,
|
||||
filters: JSON.stringify(filters),
|
||||
sort: JSON.stringify(sort),
|
||||
},
|
||||
});
|
||||
|
||||
const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {};
|
||||
|
||||
setTaskLists(data ?? []);
|
||||
setAttributes(attrs ?? []);
|
||||
setPagination(pg ?? { page: 1, limit: 10, totalRecords: 0, totalPages: 1 });
|
||||
@@ -118,29 +129,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET ARCHIVED TASKS ───────────────────────────────────────────────────
|
||||
const fetchArchivedTasks = useCallback(
|
||||
(taskListId, { page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/${taskListId}/tasks/archived`, {
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
filters: JSON.stringify(filters),
|
||||
sort: JSON.stringify(sort),
|
||||
},
|
||||
});
|
||||
|
||||
const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {};
|
||||
|
||||
setTasks(data ?? []);
|
||||
setAttributes(attrs ?? []);
|
||||
setPagination(pg ?? { page: 1, limit: 10, totalRecords: 0, totalPages: 1 });
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── CREATE ───────────────────────────────────────────────────────────────
|
||||
const createTaskList = useCallback(
|
||||
(payload) =>
|
||||
request(async () => {
|
||||
@@ -151,7 +139,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────
|
||||
const updateTaskList = useCallback(
|
||||
(taskListId, payload) =>
|
||||
request(async () => {
|
||||
@@ -162,7 +149,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── ARCHIVE ──────────────────────────────────────────────────────────────
|
||||
const archiveTaskList = useCallback(
|
||||
(taskListId) =>
|
||||
request(async () => {
|
||||
@@ -173,7 +159,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── RESTORE ──────────────────────────────────────────────────────────────
|
||||
const restoreTaskList = useCallback(
|
||||
(taskListId) =>
|
||||
request(async () => {
|
||||
@@ -184,7 +169,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── BULK ARCHIVE ─────────────────────────────────────────────────────────
|
||||
const bulkArchiveTaskLists = useCallback(
|
||||
(ids) =>
|
||||
request(async () => {
|
||||
@@ -195,7 +179,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── BULK RESTORE ─────────────────────────────────────────────────────────
|
||||
const bulkRestoreTaskLists = useCallback(
|
||||
(ids) =>
|
||||
request(async () => {
|
||||
@@ -206,12 +189,19 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
const fetchTaskListFieldValues = useCallback(
|
||||
(field) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/field-values`, { params: { field } });
|
||||
return res.data?.data ?? [];
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TASK LIST GROUPS
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── GET ASSIGNED GROUPS ──────────────────────────────────────────────────
|
||||
// GET /admin/task-lists/:taskListId/groups
|
||||
const fetchTaskListGroups = useCallback(
|
||||
(taskListId) =>
|
||||
request(async () => {
|
||||
@@ -223,11 +213,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── ASSIGN GROUPS ────────────────────────────────────────────────────────
|
||||
// POST /admin/task-lists/:taskListId/groups/assign
|
||||
// payload: { group_ids: number[] }
|
||||
//
|
||||
// Returns summary: { assigned_ids, already_assigned_ids, invalid_ids }
|
||||
const assignGroups = useCallback(
|
||||
(taskListId, groupIds) =>
|
||||
request(async () => {
|
||||
@@ -245,11 +230,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── UNASSIGN GROUPS ──────────────────────────────────────────────────────
|
||||
// POST /admin/task-lists/:taskListId/groups/unassign
|
||||
// payload: { group_ids: number[] }
|
||||
//
|
||||
// Returns summary: { unassigned_ids, skipped_ids }
|
||||
const unassignGroups = useCallback(
|
||||
(taskListId, groupIds) =>
|
||||
request(async () => {
|
||||
@@ -267,22 +247,35 @@ export function AdminTaskProvider({ children }) {
|
||||
// TASKS
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────
|
||||
const fetchTasks = useCallback(
|
||||
(taskListId, { page = 1, limit = 10, filters = [], sort = [], archived = false } = {}) =>
|
||||
(taskListId, { page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/${taskListId}/tasks`, {
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
page, limit,
|
||||
filters: JSON.stringify(filters),
|
||||
sort: JSON.stringify(sort),
|
||||
},
|
||||
});
|
||||
const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {};
|
||||
setTasks(data ?? []);
|
||||
setAttributes(attrs ?? []);
|
||||
setPagination(pg ?? { page: 1, limit: 10, totalRecords: 0, totalPages: 1 });
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
const fetchArchivedTasks = useCallback(
|
||||
(taskListId, { page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/${taskListId}/tasks/archived`, {
|
||||
params: {
|
||||
page, limit,
|
||||
filters: JSON.stringify(filters),
|
||||
sort: JSON.stringify(sort),
|
||||
archived: archived ? 'true' : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {};
|
||||
|
||||
setTasks(data ?? []);
|
||||
setAttributes(attrs ?? []);
|
||||
setPagination(pg ?? { page: 1, limit: 10, totalRecords: 0, totalPages: 1 });
|
||||
@@ -290,20 +283,17 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────
|
||||
const fetchTask = useCallback(
|
||||
(taskListId, taskId) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/${taskListId}/tasks/${taskId}`);
|
||||
const taskData = res.data?.data ?? null;
|
||||
|
||||
setTask(taskData);
|
||||
return taskData;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── CREATE ───────────────────────────────────────────────────────────────
|
||||
const createTask = useCallback(
|
||||
(taskListId, payload) =>
|
||||
request(async () => {
|
||||
@@ -314,7 +304,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── UPDATE ───────────────────────────────────────────────────────────────
|
||||
const updateTask = useCallback(
|
||||
(taskListId, taskId, payload) =>
|
||||
request(async () => {
|
||||
@@ -325,7 +314,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── ARCHIVE ──────────────────────────────────────────────────────────────
|
||||
const archiveTask = useCallback(
|
||||
(taskListId, taskId) =>
|
||||
request(async () => {
|
||||
@@ -336,7 +324,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── RESTORE ──────────────────────────────────────────────────────────────
|
||||
const restoreTask = useCallback(
|
||||
(taskListId, taskId) =>
|
||||
request(async () => {
|
||||
@@ -347,7 +334,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── BULK ARCHIVE ─────────────────────────────────────────────────────────
|
||||
const bulkArchiveTasks = useCallback(
|
||||
(taskListId, ids) =>
|
||||
request(async () => {
|
||||
@@ -358,7 +344,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── BULK RESTORE ─────────────────────────────────────────────────────────
|
||||
const bulkRestoreTasks = useCallback(
|
||||
(taskListId, ids) =>
|
||||
request(async () => {
|
||||
@@ -369,17 +354,6 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET TASK LIST FIELD VALUES ───────────────────────────────────────────────
|
||||
const fetchTaskListFieldValues = useCallback(
|
||||
(field) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/field-values`, { params: { field } });
|
||||
return res.data?.data ?? [];
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET TASK FIELD VALUES ────────────────────────────────────────────────────
|
||||
const fetchTaskFieldValues = useCallback(
|
||||
(taskListId, field) =>
|
||||
request(async () => {
|
||||
@@ -391,33 +365,201 @@ export function AdminTaskProvider({ children }) {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ── Flat lists for RequirementBuilder course/unit/lesson selectors ────────
|
||||
const fetchCoursesFlat = useCallback(
|
||||
() => request(async () => {
|
||||
const res = await api.get('/admin/courses/flat');
|
||||
return res.data?.data ?? [];
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
const fetchUnitsFlat = useCallback(
|
||||
() => request(async () => {
|
||||
const res = await api.get('/admin/courses/units-flat');
|
||||
const raw = res.data?.data ?? [];
|
||||
return raw.map((u) => ({
|
||||
...u,
|
||||
_search: `${u.course_title} unit ${u.order_index + 1} ${u.title}`.toLowerCase(),
|
||||
}));
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
const fetchLessonsFlat = useCallback(
|
||||
() => request(async () => {
|
||||
const res = await api.get('/admin/courses/lessons-flat');
|
||||
const raw = res.data?.data ?? [];
|
||||
return raw.map((l) => ({
|
||||
...l,
|
||||
_search: `${l.course_title} unit ${l.unit_order + 1} ${l.unit_title} lesson ${l.order_index + 1} ${l.title}`.toLowerCase(),
|
||||
}));
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TASK COMPLETIONS
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── GET ALL ──────────────────────────────────────────────────────────────
|
||||
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions
|
||||
const fetchCompletions = useCallback(
|
||||
(taskListId, taskId, { page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||
completionRequest(async () => {
|
||||
const res = await api.get(`${BASE}/${taskListId}/tasks/${taskId}/completions`, {
|
||||
params: {
|
||||
page, limit,
|
||||
filters: JSON.stringify(filters),
|
||||
sort: JSON.stringify(sort),
|
||||
},
|
||||
});
|
||||
const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {};
|
||||
setCompletions(data ?? []);
|
||||
setCompletionAttributes(attrs ?? []);
|
||||
setCompletionPagination(pg ?? { page: 1, limit: 10, totalRecords: 0, totalPages: 1 });
|
||||
}),
|
||||
[completionRequest]
|
||||
);
|
||||
|
||||
// ─── GET ONE ──────────────────────────────────────────────────────────────
|
||||
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/:completionId
|
||||
const fetchCompletion = useCallback(
|
||||
(taskListId, taskId, completionId) =>
|
||||
completionRequest(async () => {
|
||||
const res = await api.get(
|
||||
`${BASE}/${taskListId}/tasks/${taskId}/completions/${completionId}`
|
||||
);
|
||||
const data = res.data?.data ?? null;
|
||||
setCompletion(data);
|
||||
return data;
|
||||
}),
|
||||
[completionRequest]
|
||||
);
|
||||
|
||||
// ─── GET BY USER ──────────────────────────────────────────────────────────
|
||||
// GET /admin/task-lists/:taskListId/tasks/:taskId/completions/user/:userId
|
||||
const fetchCompletionsByUser = useCallback(
|
||||
(taskListId, taskId, userId, { page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
||||
completionRequest(async () => {
|
||||
const res = await api.get(
|
||||
`${BASE}/${taskListId}/tasks/${taskId}/completions/user/${userId}`,
|
||||
{
|
||||
params: {
|
||||
page, limit,
|
||||
filters: JSON.stringify(filters),
|
||||
sort: JSON.stringify(sort),
|
||||
},
|
||||
}
|
||||
);
|
||||
const { data, attributes: attrs, pagination: pg } = res.data?.data ?? {};
|
||||
setCompletions(data ?? []);
|
||||
setCompletionAttributes(attrs ?? []);
|
||||
setCompletionPagination(pg ?? { page: 1, limit: 10, totalRecords: 0, totalPages: 1 });
|
||||
}),
|
||||
[completionRequest]
|
||||
);
|
||||
|
||||
// ─── ARCHIVE ──────────────────────────────────────────────────────────────
|
||||
const archiveCompletion = useCallback(
|
||||
(taskListId, taskId, completionId) =>
|
||||
completionRequest(async () => {
|
||||
await api.delete(
|
||||
`${BASE}/${taskListId}/tasks/${taskId}/completions/${completionId}`
|
||||
);
|
||||
toast.success('Completion archived.');
|
||||
return true;
|
||||
}),
|
||||
[completionRequest]
|
||||
);
|
||||
|
||||
// ─── RESTORE ──────────────────────────────────────────────────────────────
|
||||
const restoreCompletion = useCallback(
|
||||
(taskListId, taskId, completionId) =>
|
||||
completionRequest(async () => {
|
||||
await api.patch(
|
||||
`${BASE}/${taskListId}/tasks/${taskId}/completions/${completionId}/restore`
|
||||
);
|
||||
toast.success('Completion restored.');
|
||||
return true;
|
||||
}),
|
||||
[completionRequest]
|
||||
);
|
||||
|
||||
// ─── BULK ARCHIVE ─────────────────────────────────────────────────────────
|
||||
const bulkArchiveCompletions = useCallback(
|
||||
(taskListId, taskId, ids) =>
|
||||
completionRequest(async () => {
|
||||
await api.post(
|
||||
`${BASE}/${taskListId}/tasks/${taskId}/completions/bulk-archive`,
|
||||
{ ids }
|
||||
);
|
||||
toast.success(`${ids.length} completion(s) archived.`);
|
||||
return true;
|
||||
}),
|
||||
[completionRequest]
|
||||
);
|
||||
|
||||
// ─── BULK RESTORE ─────────────────────────────────────────────────────────
|
||||
const bulkRestoreCompletions = useCallback(
|
||||
(taskListId, taskId, ids) =>
|
||||
completionRequest(async () => {
|
||||
await api.post(
|
||||
`${BASE}/${taskListId}/tasks/${taskId}/completions/bulk-restore`,
|
||||
{ ids }
|
||||
);
|
||||
toast.success(`${ids.length} completion(s) restored.`);
|
||||
return true;
|
||||
}),
|
||||
[completionRequest]
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<AdminTaskContext.Provider value={{
|
||||
// state
|
||||
// ── Task List state ───────────────────────────────────────────────
|
||||
taskLists, taskList,
|
||||
// ── Task state ────────────────────────────────────────────────────
|
||||
tasks, task,
|
||||
// ── Task List Groups state ────────────────────────────────────────
|
||||
taskListGroups, setTaskListGroups,
|
||||
// ── Completion state ──────────────────────────────────────────────
|
||||
completions, completion,
|
||||
// ── Shared state ──────────────────────────────────────────────────
|
||||
attributes, setAttributes,
|
||||
pagination, setPagination,
|
||||
loading,
|
||||
// ── Completion-specific state ─────────────────────────────────────
|
||||
completionPagination, setCompletionPagination,
|
||||
completionLoading,
|
||||
completionAttributes, setCompletionAttributes,
|
||||
|
||||
// task list actions
|
||||
// ── Task List actions ─────────────────────────────────────────────
|
||||
fetchTaskLists, fetchTaskList, fetchArchivedTaskLists,
|
||||
createTaskList, updateTaskList,
|
||||
archiveTaskList, restoreTaskList, fetchTaskListFieldValues,
|
||||
archiveTaskList, restoreTaskList,
|
||||
bulkArchiveTaskLists, bulkRestoreTaskLists,
|
||||
fetchTaskListFieldValues,
|
||||
|
||||
// task list group actions
|
||||
// ── Task List Group actions ───────────────────────────────────────
|
||||
fetchTaskListGroups,
|
||||
assignGroups,
|
||||
unassignGroups,
|
||||
|
||||
// task actions
|
||||
// ── Task actions ──────────────────────────────────────────────────
|
||||
fetchTasks, fetchTask, fetchArchivedTasks,
|
||||
createTask, updateTask, fetchTaskFieldValues,
|
||||
createTask, updateTask,
|
||||
archiveTask, restoreTask,
|
||||
bulkArchiveTasks, bulkRestoreTasks,
|
||||
fetchTaskFieldValues,
|
||||
|
||||
// ── Completion actions ────────────────────────────────────────────
|
||||
fetchCompletions, fetchCompletion, fetchCompletionsByUser,
|
||||
archiveCompletion, restoreCompletion,
|
||||
bulkArchiveCompletions, bulkRestoreCompletions,
|
||||
|
||||
// ── Flat lists for RequirementBuilder ────────────────────────────
|
||||
fetchCoursesFlat, fetchUnitsFlat, fetchLessonsFlat,
|
||||
}}>
|
||||
{children}
|
||||
</AdminTaskContext.Provider>
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { createContext, useContext, useState, useCallback } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const AdminTiersContext = createContext(null);
|
||||
|
||||
export function AdminTiersProvider({ children }) {
|
||||
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [plan, setPlan] = useState(null);
|
||||
const [payments, setPayments] = useState([]);
|
||||
const [payment, setPayment] = useState(null);
|
||||
const [userTiers, setUserTiers] = useState([]);
|
||||
const [planAttributes, setPlanAttributes] = useState([]);
|
||||
const [paymentAttributes, setPaymentAttributes] = useState([]);
|
||||
const [planCourses, setPlanCourses] = useState([]);
|
||||
const [planPagination, setPlanPagination] = useState({ page: 1, limit: 10, totalPages: 1, totalRecords: 0 });
|
||||
const [paymentPagination, setPaymentPagination] = useState({ page: 1, limit: 10, totalPages: 1, totalRecords: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// ─── Plans ────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetchPlans = useCallback(async ({ page = 1, limit = 10, filters = [], sort = [], archived = false } = {}) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/admin/tiers", {
|
||||
params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort), archived },
|
||||
});
|
||||
setPlans(data.data?.data ?? []);
|
||||
setPlanAttributes(data.data?.attributes ?? []);
|
||||
setPlanPagination({
|
||||
page: data.data?.pagination?.page ?? page,
|
||||
limit: data.data?.pagination?.limit ?? limit,
|
||||
totalPages: data.data?.pagination?.totalPages ?? 1,
|
||||
totalRecords: data.data?.pagination?.totalRecords ?? 0,
|
||||
});
|
||||
} catch { toast.error("Could not load plans."); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const fetchPlan = useCallback(async (id) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/admin/tiers/${id}`);
|
||||
setPlan(data.data ?? null);
|
||||
} catch { toast.error("Could not load plan."); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const createPlan = useCallback(async (payload) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post("/admin/tiers", payload);
|
||||
toast.success("Plan created.");
|
||||
return data.data;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not create plan.");
|
||||
return null;
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const updatePlan = useCallback(async (id, payload) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.put(`/admin/tiers/${id}`, payload);
|
||||
toast.success("Plan updated.");
|
||||
return data.data;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not update plan.");
|
||||
return null;
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const deletePlan = useCallback(async (id) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.delete(`/admin/tiers/${id}`);
|
||||
toast.success("Plan archived.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not archive plan.");
|
||||
return false;
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const restorePlan = useCallback(async (id) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post(`/admin/tiers/${id}/restore`);
|
||||
toast.success("Plan restored.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not restore plan.");
|
||||
return false;
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const bulkDeletePlans = useCallback(async (ids) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post("/admin/tiers/bulk/archive", { ids });
|
||||
toast.success("Plans archived.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not archive plans.");
|
||||
return false;
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const bulkRestorePlans = useCallback(async (ids) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post("/admin/tiers/bulk/restore", { ids });
|
||||
toast.success("Plans restored.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not restore plans.");
|
||||
return false;
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
// ─── User Tiers ───────────────────────────────────────────────────────────
|
||||
|
||||
const fetchUserTiers = useCallback(async (userId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/admin/tiers/users/${userId}/tiers`);
|
||||
setUserTiers(data.data ?? []);
|
||||
} catch { toast.error("Could not load user tiers."); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const grantTier = useCallback(async (payload) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post("/admin/tiers/users/tiers/grant", payload);
|
||||
toast.success("Tier granted.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not grant tier.");
|
||||
return false;
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const revokeTier = useCallback(async (tid) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.patch(`/admin/tiers/users/tiers/${tid}/revoke`);
|
||||
toast.success("Tier revoked.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not revoke tier.");
|
||||
return false;
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
// ─── Payments ─────────────────────────────────────────────────────────────
|
||||
|
||||
const fetchPayments = useCallback(async ({ page = 1, limit = 10, filters = [], sort = [] } = {}) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/admin/tiers/payments", {
|
||||
params: { page, limit, filters: JSON.stringify(filters), sort: JSON.stringify(sort) },
|
||||
});
|
||||
setPayments(data.data?.data ?? []);
|
||||
setPaymentAttributes(data.data?.attributes ?? []);
|
||||
setPaymentPagination({
|
||||
page: data.data?.pagination?.page ?? page,
|
||||
limit: data.data?.pagination?.limit ?? limit,
|
||||
totalPages: data.data?.pagination?.totalPages ?? 1,
|
||||
totalRecords: data.data?.pagination?.totalRecords ?? 0,
|
||||
});
|
||||
} catch { toast.error("Could not load payments."); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const fetchPayment = useCallback(async (id) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/admin/tiers/payments/${id}`);
|
||||
setPayment(data.data ?? null);
|
||||
} catch { toast.error("Could not load payment."); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const fetchPlanCourses = useCallback(async (planId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/admin/tiers/${planId}/courses`);
|
||||
setPlanCourses(data.data ?? []);
|
||||
} catch { toast.error("Could not load plan courses."); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
const syncPlanCourses = useCallback(async (planId, courseIds) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.post(`/admin/tiers/${planId}/courses`, { course_ids: courseIds });
|
||||
toast.success("Courses updated.");
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not update courses.");
|
||||
return false;
|
||||
} finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<AdminTiersContext.Provider value={{
|
||||
// State
|
||||
plans, plan,
|
||||
payments, payment,
|
||||
userTiers,
|
||||
planAttributes, paymentAttributes,
|
||||
planPagination, setPlanPagination,
|
||||
paymentPagination, setPaymentPagination,
|
||||
loading,
|
||||
|
||||
// Plan actions
|
||||
fetchPlans, fetchPlan,
|
||||
createPlan, updatePlan,
|
||||
deletePlan, restorePlan,
|
||||
bulkDeletePlans, bulkRestorePlans,
|
||||
|
||||
// User tier actions
|
||||
fetchUserTiers, grantTier, revokeTier,
|
||||
|
||||
// Payment actions
|
||||
fetchPayments, fetchPayment,
|
||||
|
||||
// Course plans
|
||||
planCourses, fetchPlanCourses, syncPlanCourses,
|
||||
}}>
|
||||
{children}
|
||||
</AdminTiersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTiers = () => useContext(AdminTiersContext);
|
||||
@@ -25,10 +25,15 @@ export const UserProvider = ({ children }) => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [achievements, setAchievements] = useState([]);
|
||||
const [achievementsLoading, setAchievementsLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
||||
const [attributes, setAttributes] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [activity, setActivity] = useState([]);
|
||||
const [activityPagination, setActivityPagination] = useState(PAGINATION_INIT);
|
||||
const [activityLoading, setActivityLoading] = useState(false);
|
||||
|
||||
const request = useCallback(async (fn) => {
|
||||
setLoading(true);
|
||||
@@ -212,6 +217,53 @@ export const UserProvider = ({ children }) => {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET /api/admin/activity ──────────────────────────────────────────────
|
||||
const fetchActivity = useCallback(
|
||||
({ page = 1, limit = 20, action = undefined, from = undefined, to = undefined } = {}) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/activity`, { params: { page, limit, action, from, to } });
|
||||
const d = res.data?.data;
|
||||
setActivity(d?.activities ?? []);
|
||||
setActivityPagination({
|
||||
page: d?.page ?? 1,
|
||||
limit,
|
||||
totalRecords: d?.total ?? 0,
|
||||
totalPages: d?.totalPages ?? 0,
|
||||
hasPrevPage: (d?.page ?? 1) > 1,
|
||||
hasNextPage: (d?.page ?? 1) < (d?.totalPages ?? 0),
|
||||
});
|
||||
return d;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET /api/admin/users/:id/activity ────────────────────────────────────
|
||||
const fetchUserActivity = useCallback(
|
||||
async (userId, { page = 1, limit = 20, action = undefined } = {}) => {
|
||||
setActivityLoading(true);
|
||||
try {
|
||||
const res = await api.get(`${BASE}/users/${userId}/activity`, { params: { page, limit, action } });
|
||||
const d = res.data?.data;
|
||||
setActivity(d?.activities ?? []);
|
||||
setActivityPagination({
|
||||
page: d?.page ?? 1,
|
||||
limit,
|
||||
totalRecords: d?.total ?? 0,
|
||||
totalPages: d?.totalPages ?? 0,
|
||||
hasPrevPage: (d?.page ?? 1) > 1,
|
||||
hasNextPage: (d?.page ?? 1) < (d?.totalPages ?? 0),
|
||||
});
|
||||
return d;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load activity.");
|
||||
return null;
|
||||
} finally {
|
||||
setActivityLoading(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ─── GET /api/admin/users/field-values ────────────────────────────────────
|
||||
const fetchUserFieldValues = useCallback(
|
||||
(field) =>
|
||||
@@ -222,9 +274,25 @@ export const UserProvider = ({ children }) => {
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET /api/admin/users/:id/achievements ────────────────────────────────
|
||||
const fetchUserAchievements = useCallback(async (userId) => {
|
||||
setAchievementsLoading(true);
|
||||
try {
|
||||
const res = await api.get(`${BASE}/users/${userId}/achievements`);
|
||||
setAchievements(res.data?.data ?? []);
|
||||
return res.data?.data;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load achievements.");
|
||||
return [];
|
||||
} finally {
|
||||
setAchievementsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{
|
||||
users, user, sessions, pagination, attributes, loading, error,
|
||||
users, user, sessions, achievements, achievementsLoading, pagination, attributes, loading, error,
|
||||
activity, activityPagination, activityLoading,
|
||||
setPagination,
|
||||
fetchUsers, fetchArchivedUsers, fetchUser,
|
||||
addStaffUser, updateUser,
|
||||
@@ -232,6 +300,8 @@ export const UserProvider = ({ children }) => {
|
||||
restoreUser, restoreUsers,
|
||||
fetchUserSessions, terminateSession,
|
||||
fetchUserFieldValues,
|
||||
fetchUserAchievements,
|
||||
fetchActivity, fetchUserActivity,
|
||||
}}>
|
||||
{children}
|
||||
</UserContext.Provider>
|
||||
|
||||
@@ -9,6 +9,7 @@ export const decodeToken = (token) => JSON.parse(atob(token.split('.')[1]))
|
||||
export function AuthProvider({ children }) {
|
||||
const [accessToken, _setAccessToken] = useState(null)
|
||||
const [user, setUser] = useState(null)
|
||||
const [sessionId, setSessionId] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sessionRestored, setSessionRestored] = useState(false)
|
||||
const [authError, setAuthError] = useState(null)
|
||||
@@ -28,6 +29,7 @@ export function AuthProvider({ children }) {
|
||||
const { data } = await api.post('/auth/login', { email, password })
|
||||
setAccessToken(data.data.accessToken)
|
||||
setUser(data.data.user)
|
||||
setSessionId(data.data.session_id ?? null)
|
||||
return { success: true, user: data.data.user }
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.message || 'Login failed. Please try again.'
|
||||
@@ -56,6 +58,7 @@ export function AuthProvider({ children }) {
|
||||
const { data } = await api.post('/auth/verify-otp', { email, otp })
|
||||
setAccessToken(data.data.accessToken)
|
||||
setUser(data.data.user)
|
||||
setSessionId(data.data.session_id ?? null)
|
||||
return { success: true, user: data.data.user }
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.message || 'OTP verification failed.'
|
||||
@@ -78,14 +81,15 @@ export function AuthProvider({ children }) {
|
||||
// ── Logout ─────────────────────────────────────────────────────────────────
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
await api.post('/auth/logout', { session_id: sessionId })
|
||||
} catch (_) {
|
||||
// ignore
|
||||
} finally {
|
||||
setAccessToken(null)
|
||||
setUser(null)
|
||||
setSessionId(null)
|
||||
}
|
||||
}, [])
|
||||
}, [sessionId])
|
||||
|
||||
// ── Restore session ────────────────────────────────────────────────────────
|
||||
const restoreSession = useCallback(async () => {
|
||||
@@ -97,9 +101,11 @@ export function AuthProvider({ children }) {
|
||||
const { data } = await api.post('/auth/refresh')
|
||||
setAccessToken(data.data.accessToken)
|
||||
setUser(data.data.user)
|
||||
setSessionId(data.data.session_id ?? null)
|
||||
} catch (_) {
|
||||
setAccessToken(null)
|
||||
setUser(null)
|
||||
setSessionId(null)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -112,6 +118,7 @@ export function AuthProvider({ children }) {
|
||||
setAccessToken,
|
||||
user,
|
||||
setUser,
|
||||
sessionId,
|
||||
login,
|
||||
register,
|
||||
verifyOTP,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
const ClientAdvertisementsContext = createContext(null);
|
||||
|
||||
export function useClientAdvertisements() {
|
||||
const ctx = useContext(ClientAdvertisementsContext);
|
||||
if (!ctx) throw new Error("useClientAdvertisements must be used within a ClientAdvertisementsProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ClientAdvertisementsProvider({ children }) {
|
||||
// Keyed by type so hero + popup (or any combo) can be fetched independently
|
||||
// without clobbering each other: { hero: {...}, popup: {...} }
|
||||
const [advertisements, setAdvertisements] = useState({});
|
||||
const [loading, setLoading] = useState({});
|
||||
|
||||
// ─── GET /api/client/advertisements/active?type=hero ──────────────────────
|
||||
const getActiveAdvertisement = useCallback(
|
||||
async (type) => {
|
||||
setLoading((prev) => ({ ...prev, [type]: true }));
|
||||
try {
|
||||
const { data } = await api.get("/client/advertisements/active", { params: { type } });
|
||||
const ad = data?.data?.data ?? null;
|
||||
setAdvertisements((prev) => ({ ...prev, [type]: ad }));
|
||||
return ad;
|
||||
} catch {
|
||||
setAdvertisements((prev) => ({ ...prev, [type]: null }));
|
||||
return null;
|
||||
} finally {
|
||||
setLoading((prev) => ({ ...prev, [type]: false }));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ─── POST /api/client/advertisements/:advertisementId/click ───────────────
|
||||
// Fire-and-forget — never await this on a navigation-blocking path.
|
||||
const trackClick = useCallback(
|
||||
(advertisementId) => {
|
||||
if (!advertisementId) return;
|
||||
api.post(`/client/advertisements/${advertisementId}/click`).catch(() => {});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<ClientAdvertisementsContext.Provider value={{
|
||||
advertisements,
|
||||
loading,
|
||||
getActiveAdvertisement,
|
||||
trackClick,
|
||||
}}>
|
||||
{children}
|
||||
</ClientAdvertisementsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : ClientCourseReadingProgressContext.jsx
|
||||
* Type : Context / Provider
|
||||
* Description : Tracks a user's reading progress through a course (course → unit → lesson).
|
||||
*
|
||||
* Progress is stored as a flat map for O(1) lookups:
|
||||
* progressMap : { [reference_id (uuid)]: 'in_progress' | 'completed' }
|
||||
*
|
||||
* Optimistic updates are applied immediately on UPSERT;
|
||||
* the map is reconciled with the server response for all three levels
|
||||
* (lesson / unit / course) after each call.
|
||||
*
|
||||
* Used by: UnitList.jsx (sidebar indicators + scroll-triggered completion)
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import api from '@/utils/api.util';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const CourseReadingProgressContext = createContext(null);
|
||||
|
||||
export function useCourseReadingProgress() {
|
||||
const ctx = useContext(CourseReadingProgressContext);
|
||||
if (!ctx) throw new Error('useCourseReadingProgress must be used within a CourseReadingProgressProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function CourseReadingProgressProvider({ children }) {
|
||||
// { [reference_id]: 'in_progress' | 'completed' }
|
||||
const [progressMap, setProgressMap] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// ─── Lookup helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const isRead = useCallback(
|
||||
(referenceId) => !!progressMap[referenceId],
|
||||
[progressMap]
|
||||
);
|
||||
|
||||
const isCompleted = useCallback(
|
||||
(referenceId) => progressMap[referenceId] === 'completed',
|
||||
[progressMap]
|
||||
);
|
||||
|
||||
// ─── Fetch full progress snapshot ─────────────────────────────────────────
|
||||
|
||||
// GET /client/courses/:courseId/progress
|
||||
// Called on UnitList mount — seeds the map for the whole course.
|
||||
const fetchCourseProgress = useCallback(async (courseId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/client/courses/${courseId}/progress`);
|
||||
const rows = data.data ?? [];
|
||||
setProgressMap(
|
||||
Object.fromEntries(rows.map((r) => [r.reference_id, r.status]))
|
||||
);
|
||||
return rows;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? 'Could not load course progress.');
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── UPSERT lesson progress ───────────────────────────────────────────────
|
||||
|
||||
// POST /client/courses/:courseId/units/:unitId/lessons/:lessonId/progress
|
||||
// Body: { status: 'in_progress' | 'completed' }
|
||||
//
|
||||
// Optimistically patches the lesson in the map, then reconciles lesson + unit + course
|
||||
// from the server response so all sidebar indicators update without a full refetch.
|
||||
const upsertLessonProgress = useCallback(async (courseId, unitId, lessonId, lessonUuid, status) => {
|
||||
// Optimistic update — lesson only
|
||||
setProgressMap((prev) => ({ ...prev, [lessonUuid]: status }));
|
||||
|
||||
try {
|
||||
const { data } = await api.post(
|
||||
`/client/courses/${courseId}/units/${unitId}/lessons/${lessonId}/progress`,
|
||||
{ status }
|
||||
);
|
||||
|
||||
const result = data.data ?? {};
|
||||
|
||||
// Reconcile all three levels from server
|
||||
setProgressMap((prev) => {
|
||||
const next = { ...prev };
|
||||
if (result.lesson) next[result.lesson.reference_id] = result.lesson.status;
|
||||
if (result.unit) next[result.unit.reference_id] = result.unit.status;
|
||||
if (result.course) next[result.course.reference_id] = result.course.status;
|
||||
return next;
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
// Rollback optimistic update
|
||||
setProgressMap((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[lessonUuid];
|
||||
return next;
|
||||
});
|
||||
toast.error(err?.response?.data?.message ?? 'Could not update progress.');
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── Reset — call when leaving a course ──────────────────────────────────
|
||||
|
||||
const resetProgress = useCallback(() => setProgressMap({}), []);
|
||||
|
||||
return (
|
||||
<CourseReadingProgressContext.Provider value={{
|
||||
progressMap,
|
||||
loading,
|
||||
isRead,
|
||||
isCompleted,
|
||||
fetchCourseProgress,
|
||||
upsertLessonProgress,
|
||||
resetProgress,
|
||||
}}>
|
||||
{children}
|
||||
</CourseReadingProgressContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ClientCoursesContext = createContext(null);
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ClientCoursesProvider({ children }) {
|
||||
// ── List state
|
||||
const [courses, setCourses] = useState([]);
|
||||
const [coursesLoading, setCoursesLoading] = useState(false);
|
||||
|
||||
// ── Detail state
|
||||
const [course, setCourse] = useState(null);
|
||||
const [courseLoading, setCourseLoading] = useState(false);
|
||||
const [courseBlocked, setCourseBlocked] = useState(false); // true = 403, prompt upgrade
|
||||
|
||||
// ── Unit state
|
||||
const [unit, setUnit] = useState(null);
|
||||
const [unitLoading, setUnitLoading] = useState(false);
|
||||
|
||||
// ── Lesson state
|
||||
const [lesson, setLesson] = useState(null);
|
||||
const [lessonLoading, setLessonLoading] = useState(false);
|
||||
|
||||
// ── Quiz state
|
||||
const [quiz, setQuiz] = useState(null);
|
||||
const [quizLoading, setQuizLoading] = useState(false);
|
||||
|
||||
// ── Assessment state
|
||||
const [assessment, setAssessment] = useState(null);
|
||||
const [assessmentLoading, setAssessmentLoading] = useState(false);
|
||||
|
||||
// ─── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
const getCourses = useCallback(async () => {
|
||||
setCoursesLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/client/courses");
|
||||
setCourses(data.data ?? []);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load courses.");
|
||||
} finally {
|
||||
setCoursesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getCourse = useCallback(async (courseId) => {
|
||||
setCourseLoading(true);
|
||||
setCourseBlocked(false);
|
||||
try {
|
||||
const { data } = await api.get(`/client/courses/${courseId}`);
|
||||
setCourse(data.data ?? null);
|
||||
} catch (err) {
|
||||
if (err?.response?.status === 403) {
|
||||
setCourseBlocked(true); // let the UI show an upgrade prompt
|
||||
} else {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load course.");
|
||||
}
|
||||
} finally {
|
||||
setCourseLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getUnit = useCallback(async (courseId, unitId) => {
|
||||
setUnitLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/client/courses/${courseId}/units/${unitId}`);
|
||||
setUnit(data.data ?? null);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load unit.");
|
||||
} finally {
|
||||
setUnitLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getLesson = useCallback(async (courseId, unitId, lessonId) => {
|
||||
setLessonLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(
|
||||
`/client/courses/${courseId}/units/${unitId}/lessons/${lessonId}`
|
||||
);
|
||||
setLesson(data.data ?? null);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load lesson.");
|
||||
} finally {
|
||||
setLessonLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getUnitQuiz = useCallback(async (courseId, unitId) => {
|
||||
setQuizLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(
|
||||
`/client/courses/${courseId}/units/${unitId}/quiz`
|
||||
);
|
||||
setQuiz(data.data ?? null);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load quiz.");
|
||||
} finally {
|
||||
setQuizLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getCourseAssessment = useCallback(async (courseId) => {
|
||||
setAssessmentLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/client/courses/${courseId}/assessment`);
|
||||
setAssessment(data.data ?? null);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load assessment.");
|
||||
} finally {
|
||||
setAssessmentLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const submitUnitQuiz = useCallback(async (courseId, unitId, quizId, answers) => {
|
||||
try {
|
||||
const { data } = await api.post(
|
||||
`/client/courses/${courseId}/units/${unitId}/quiz/${quizId}/submit`,
|
||||
{ answers }
|
||||
);
|
||||
return data.data ?? null;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not submit quiz.");
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const submitCourseAssessment = useCallback(async (courseId, assessmentId, answers) => {
|
||||
try {
|
||||
const { data } = await api.post(
|
||||
`/client/courses/${courseId}/assessment/${assessmentId}/submit`,
|
||||
{ answers }
|
||||
);
|
||||
return data.data ?? null;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not submit assessment.");
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── Course Purchases ───────────────────────────────────────────────────────
|
||||
|
||||
const [purchases, setPurchases] = useState([]);
|
||||
const [purchasesLoading, setPurchasesLoading] = useState(false);
|
||||
const [purchaseLoading, setPurchaseLoading] = useState(false);
|
||||
|
||||
const getMyPurchases = useCallback(async () => {
|
||||
setPurchasesLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/client/course-purchases");
|
||||
setPurchases(data.data ?? []);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load purchases.");
|
||||
} finally { setPurchasesLoading(false); }
|
||||
}, []);
|
||||
|
||||
const createCourseOrder = useCallback(async (productId) => {
|
||||
setPurchaseLoading(true);
|
||||
try {
|
||||
const { data } = await api.post("/client/course-purchases/order", { product_id: productId });
|
||||
return data.data ?? null;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not create order.");
|
||||
return null;
|
||||
} finally { setPurchaseLoading(false); }
|
||||
}, []);
|
||||
|
||||
const captureCourseOrder = useCallback(async (orderId) => {
|
||||
setPurchaseLoading(true);
|
||||
try {
|
||||
const { data } = await api.post("/client/course-purchases/capture", { order_id: orderId });
|
||||
toast.success("Purchase confirmed! You now have access to this course.");
|
||||
return data.data ?? null;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not capture payment.");
|
||||
return null;
|
||||
} finally { setPurchaseLoading(false); }
|
||||
}, []);
|
||||
|
||||
const cancelCourseOrder = useCallback(async (orderId) => {
|
||||
try {
|
||||
await api.post("/client/course-purchases/cancel", { order_id: orderId });
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
// ─── Reset helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const resetCourse = useCallback(() => { setCourse(null); setCourseBlocked(false); }, []);
|
||||
const resetUnit = useCallback(() => setUnit(null), []);
|
||||
const resetLesson = useCallback(() => setLesson(null), []);
|
||||
const resetQuiz = useCallback(() => setQuiz(null), []);
|
||||
const resetAssessment = useCallback(() => setAssessment(null), []);
|
||||
|
||||
// ─── Value ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const value = {
|
||||
// state
|
||||
courses, coursesLoading,
|
||||
course, courseLoading, courseBlocked,
|
||||
unit, unitLoading,
|
||||
lesson, lessonLoading,
|
||||
quiz, quizLoading,
|
||||
assessment, assessmentLoading,
|
||||
|
||||
// actions
|
||||
getCourses,
|
||||
getCourse,
|
||||
getUnit,
|
||||
getLesson,
|
||||
getUnitQuiz,
|
||||
getCourseAssessment,
|
||||
submitUnitQuiz,
|
||||
submitCourseAssessment,
|
||||
|
||||
// purchases
|
||||
purchases, purchasesLoading, purchaseLoading,
|
||||
getMyPurchases,
|
||||
createCourseOrder,
|
||||
captureCourseOrder,
|
||||
cancelCourseOrder,
|
||||
|
||||
// resets
|
||||
resetCourse,
|
||||
resetUnit,
|
||||
resetLesson,
|
||||
resetQuiz,
|
||||
resetAssessment,
|
||||
};
|
||||
|
||||
return (
|
||||
<ClientCoursesContext.Provider value={value}>
|
||||
{children}
|
||||
</ClientCoursesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useClientCourses() {
|
||||
const ctx = useContext(ClientCoursesContext);
|
||||
if (!ctx) throw new Error("useClientCourses must be used within ClientCoursesProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export default ClientCoursesContext;
|
||||
@@ -0,0 +1,75 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : GroupContext.jsx
|
||||
* Type : Context / Provider
|
||||
* Description : Client group context.
|
||||
* Covers: fetching the current user's groups and a single group detail.
|
||||
* Used by: GroupList.jsx
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import api from '@/utils/api.util';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const BASE = '/client/groups';
|
||||
|
||||
const GroupContext = createContext(null);
|
||||
|
||||
export function useGroup() {
|
||||
const ctx = useContext(GroupContext);
|
||||
if (!ctx) throw new Error('useGroup must be used within a GroupProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function GroupProvider({ children }) {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [group, setGroup] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const request = useCallback(async (fn) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
||||
toast.error(message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── GET MY GROUPS ────────────────────────────────────────────────────────
|
||||
// GET /client/groups
|
||||
const fetchGroups = useCallback(
|
||||
() =>
|
||||
request(async () => {
|
||||
const res = await api.get(BASE);
|
||||
const data = res.data?.data ?? [];
|
||||
setGroups(data);
|
||||
return data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─── GET ONE GROUP ────────────────────────────────────────────────────────
|
||||
// GET /client/groups/:groupId
|
||||
const fetchGroup = useCallback(
|
||||
(groupId) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/${groupId}`);
|
||||
const data = res.data?.data ?? null;
|
||||
setGroup(data);
|
||||
return data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={{
|
||||
groups, group,
|
||||
loading,
|
||||
fetchGroups, fetchGroup,
|
||||
}}>
|
||||
{children}
|
||||
</GroupContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
const ClientNotificationContext = createContext(null);
|
||||
|
||||
const POLL_INTERVAL = 60_000;
|
||||
|
||||
export function useClientNotifications() {
|
||||
const ctx = useContext(ClientNotificationContext);
|
||||
if (!ctx) throw new Error("useClientNotifications must be used within ClientNotificationProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ClientNotificationProvider({ children }) {
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [unseenCount, setUnseenCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const intervalRef = useRef(null);
|
||||
|
||||
const fetchUnseen = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get("/client/notifications/unseen");
|
||||
setUnseenCount(res.data?.data?.count ?? 0);
|
||||
} catch {
|
||||
// silent — badge stays at last known count
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get("/client/notifications?limit=20");
|
||||
const rows = res.data?.data?.notifications ?? [];
|
||||
setNotifications(rows);
|
||||
setUnseenCount(rows.filter(n => !n.seen).length);
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markSeen = useCallback(async (id) => {
|
||||
try {
|
||||
await api.patch(`/client/notifications/${id}/seen`);
|
||||
setNotifications(prev =>
|
||||
prev.map(n => n.notification_id === id ? { ...n, seen: true } : n)
|
||||
);
|
||||
setUnseenCount(prev => Math.max(0, prev - 1));
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markAllSeen = useCallback(async () => {
|
||||
try {
|
||||
await api.patch("/client/notifications/seen-all");
|
||||
setNotifications(prev => prev.map(n => ({ ...n, seen: true })));
|
||||
setUnseenCount(0);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnseen();
|
||||
intervalRef.current = setInterval(fetchUnseen, POLL_INTERVAL);
|
||||
return () => clearInterval(intervalRef.current);
|
||||
}, [fetchUnseen]);
|
||||
|
||||
return (
|
||||
<ClientNotificationContext.Provider value={{
|
||||
notifications,
|
||||
unseenCount,
|
||||
loading,
|
||||
fetchNotifications,
|
||||
markSeen,
|
||||
markAllSeen,
|
||||
}}>
|
||||
{children}
|
||||
</ClientNotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : TaskContext.jsx
|
||||
* Type : Context / Provider
|
||||
* Description : Client task context.
|
||||
* Covers: task lists for a group (with status filter), single task list,
|
||||
* single task with requirements + latest completion,
|
||||
* completion history, and task completion (submit).
|
||||
* Used by: GroupList.jsx (task lists), ViewTaskDetails.jsx (task + completions)
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import api from '@/utils/api.util';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const BASE = '/client/groups';
|
||||
|
||||
const TaskContext = createContext(null);
|
||||
|
||||
export function useTask() {
|
||||
const ctx = useContext(TaskContext);
|
||||
if (!ctx) throw new Error('useTask must be used within a TaskProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function TaskProvider({ children }) {
|
||||
// ── Task List state ───────────────────────────────────────────────────────
|
||||
const [taskLists, setTaskLists] = useState([]);
|
||||
const [taskList, setTaskList] = useState(null);
|
||||
|
||||
// ── Task state ────────────────────────────────────────────────────────────
|
||||
const [task, setTask] = useState(null);
|
||||
|
||||
// ── Completion state ──────────────────────────────────────────────────────
|
||||
// latest_completion is embedded in task from getTask — kept separately
|
||||
// so the submit action can update it without refetching the whole task.
|
||||
const [completions, setCompletions] = useState([]); // full history
|
||||
const [latestCompletion, setLatestCompletion] = useState(null);
|
||||
|
||||
// ── Shared ────────────────────────────────────────────────────────────────
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [completionLoading, setCompletionLoading] = useState(false);
|
||||
|
||||
const request = useCallback(async (fn) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
||||
toast.error(message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const completionRequest = useCallback(async (fn) => {
|
||||
setCompletionLoading(true);
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
||||
toast.error(message);
|
||||
return null;
|
||||
} finally {
|
||||
setCompletionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TASK LISTS
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── GET TASK LISTS FOR GROUP ─────────────────────────────────────────────
|
||||
// GET /client/groups/:groupId/task-lists?status=ongoing|done|overdue
|
||||
const fetchTaskLists = useCallback(
|
||||
(groupId, { status } = {}) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/${groupId}/task-lists`, {
|
||||
params: status ? { status } : {},
|
||||
});
|
||||
const data = res.data?.data ?? [];
|
||||
setTaskLists(data);
|
||||
return data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PATCH: fetchTaskList in TaskContext.jsx (client) — add params support
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetchTaskList = useCallback(
|
||||
(groupId, taskListId, params = {}) =>
|
||||
request(async () => {
|
||||
const res = await api.get(`${BASE}/${groupId}/task-lists/${taskListId}`, { params });
|
||||
const data = res.data?.data ?? null;
|
||||
setTaskList(data);
|
||||
return data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// TASKS
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── GET ONE TASK ─────────────────────────────────────────────────────────
|
||||
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId
|
||||
// Response includes requirements[] and latest_completion
|
||||
const fetchTask = useCallback(
|
||||
(groupId, taskListId, taskId) =>
|
||||
request(async () => {
|
||||
const res = await api.get(
|
||||
`${BASE}/${groupId}/task-lists/${taskListId}/tasks/${taskId}`
|
||||
);
|
||||
const data = res.data?.data ?? null;
|
||||
setTask(data);
|
||||
// Seed latest completion from the embedded payload
|
||||
if (data?.latest_completion !== undefined) {
|
||||
setLatestCompletion(data.latest_completion);
|
||||
}
|
||||
return data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// COMPLETIONS
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ─── GET MY COMPLETION HISTORY ────────────────────────────────────────────
|
||||
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions
|
||||
const fetchCompletions = useCallback(
|
||||
(groupId, taskListId, taskId) =>
|
||||
completionRequest(async () => {
|
||||
const res = await api.get(
|
||||
`${BASE}/${groupId}/task-lists/${taskListId}/tasks/${taskId}/completions`
|
||||
);
|
||||
const data = res.data?.data ?? [];
|
||||
setCompletions(data);
|
||||
// Keep latest in sync
|
||||
if (data.length) setLatestCompletion(data[0]);
|
||||
return data;
|
||||
}),
|
||||
[completionRequest]
|
||||
);
|
||||
|
||||
// ─── COMPLETE TASK (SUBMIT) ───────────────────────────────────────────────
|
||||
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/completions
|
||||
// Body: { note?: string, files: [{ file_url, file_name, file_size?, mime_type? }] }
|
||||
const completeTask = useCallback(
|
||||
(groupId, taskListId, taskId, payload) =>
|
||||
completionRequest(async () => {
|
||||
const res = await api.post(
|
||||
`${BASE}/${groupId}/task-lists/${taskListId}/tasks/${taskId}/completions`,
|
||||
payload
|
||||
);
|
||||
const data = res.data?.data ?? null;
|
||||
toast.success('Task submitted successfully.');
|
||||
// Immediately update latest completion so UI reflects the new state
|
||||
setLatestCompletion(data);
|
||||
// Prepend to history if it's already loaded
|
||||
setCompletions((prev) => (prev.length ? [data, ...prev] : prev));
|
||||
return data;
|
||||
}),
|
||||
[completionRequest]
|
||||
);
|
||||
|
||||
return (
|
||||
<TaskContext.Provider value={{
|
||||
// ── State ─────────────────────────────────────────────────────────
|
||||
taskLists, taskList,
|
||||
task,
|
||||
completions, latestCompletion,
|
||||
loading, completionLoading,
|
||||
|
||||
// ── Task List actions ─────────────────────────────────────────────
|
||||
fetchTaskLists, fetchTaskList,
|
||||
|
||||
// ── Task actions ──────────────────────────────────────────────────
|
||||
fetchTask,
|
||||
|
||||
// ── Completion actions ────────────────────────────────────────────
|
||||
fetchCompletions, completeTask,
|
||||
}}>
|
||||
{children}
|
||||
</TaskContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : TaskProgressContext.jsx
|
||||
* Type : Context / Provider
|
||||
* Description : Client task progress context.
|
||||
* Covers: fetching the full progress snapshot for a task,
|
||||
* UPSERT link visits, and UPSERT lesson/unit/course progress.
|
||||
*
|
||||
* Progress is stored locally as two maps for O(1) lookups by the blocks:
|
||||
* visitedMap : { [requirement_id]: visited_at }
|
||||
* progressMap : { [`${requirement_id}:${reference_id}`]: { completed, completed_at } }
|
||||
*
|
||||
* Used by: ViewTaskDetails.jsx, ViewRequirement.jsx,
|
||||
* VisitLink block, ReadCourse block, ReadUnit block, ReadLesson block
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { createContext, useCallback, useContext, useState } from 'react';
|
||||
import api from '@/utils/api.util';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const BASE = '/client/groups';
|
||||
|
||||
const TaskProgressContext = createContext(null);
|
||||
|
||||
export function useTaskProgress() {
|
||||
const ctx = useContext(TaskProgressContext);
|
||||
if (!ctx) throw new Error('useTaskProgress must be used within a TaskProgressProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function TaskProgressProvider({ children }) {
|
||||
// ── Raw arrays from API ───────────────────────────────────────────────────
|
||||
const [linkVisits, setLinkVisits] = useState([]);
|
||||
const [progress, setProgress] = useState([]);
|
||||
|
||||
// ── Derived lookup maps (rebuilt on every fetch/upsert) ───────────────────
|
||||
// visitedMap : { [requirement_id]: visited_at }
|
||||
const [visitedMap, setVisitedMap] = useState({});
|
||||
// progressMap : { [`${requirement_id}:${reference_id}`]: { completed, completed_at } }
|
||||
const [progressMap, setProgressMap] = useState({});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const request = useCallback(async (fn) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
const message = err?.response?.data?.message ?? 'Something went wrong.';
|
||||
toast.error(message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── Helpers to rebuild maps ──────────────────────────────────────────────
|
||||
const buildVisitedMap = (visits) =>
|
||||
Object.fromEntries(visits.map((v) => [v.requirement_id, v.visited_at]));
|
||||
|
||||
const buildProgressMap = (rows) =>
|
||||
Object.fromEntries(
|
||||
rows.map((r) => [
|
||||
`${r.requirement_id}:${r.reference_id}`,
|
||||
{ completed: r.completed, completed_at: r.completed_at },
|
||||
])
|
||||
);
|
||||
|
||||
// ─── Convenience checkers exposed to blocks ───────────────────────────────
|
||||
// isVisited(requirement_id) → boolean
|
||||
const isVisited = useCallback(
|
||||
(requirementId) => !!visitedMap[requirementId],
|
||||
[visitedMap]
|
||||
);
|
||||
|
||||
// isCompleted(requirement_id, reference_id) → boolean
|
||||
const isCompleted = useCallback(
|
||||
(requirementId, referenceId) =>
|
||||
!!progressMap[`${requirementId}:${referenceId}`]?.completed,
|
||||
[progressMap]
|
||||
);
|
||||
|
||||
// getProgress(requirement_id, reference_id) → { completed, completed_at } | null
|
||||
const getProgress = useCallback(
|
||||
(requirementId, referenceId) =>
|
||||
progressMap[`${requirementId}:${referenceId}`] ?? null,
|
||||
[progressMap]
|
||||
);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// FETCH FULL PROGRESS SNAPSHOT
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// GET /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/progress
|
||||
// Called once on ViewTaskDetails mount — seeds both maps.
|
||||
const fetchProgress = useCallback(
|
||||
(groupId, taskListId, taskId) =>
|
||||
request(async () => {
|
||||
const res = await api.get(
|
||||
`${BASE}/${groupId}/task-lists/${taskListId}/tasks/${taskId}/progress`
|
||||
);
|
||||
const { link_visits = [], progress: rows = [] } = res.data?.data ?? {};
|
||||
|
||||
setLinkVisits(link_visits);
|
||||
setProgress(rows);
|
||||
setVisitedMap(buildVisitedMap(link_visits));
|
||||
setProgressMap(buildProgressMap(rows));
|
||||
|
||||
return { link_visits, progress: rows };
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// VISIT LINK (UPSERT)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/visit
|
||||
// Optimistically updates visitedMap before the request resolves.
|
||||
const visitLink = useCallback(
|
||||
(groupId, taskListId, taskId, requirementId) =>
|
||||
request(async () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Optimistic update
|
||||
setVisitedMap((prev) => ({ ...prev, [requirementId]: now }));
|
||||
|
||||
const res = await api.post(
|
||||
`${BASE}/${groupId}/task-lists/${taskListId}/tasks/${taskId}/requirements/${requirementId}/visit`
|
||||
);
|
||||
|
||||
const { visited_at } = res.data?.data ?? {};
|
||||
|
||||
// Reconcile with server timestamp
|
||||
setVisitedMap((prev) => ({ ...prev, [requirementId]: visited_at ?? now }));
|
||||
|
||||
return res.data?.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// UPDATE LESSON PROGRESS (UPSERT — derives unit + course)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// POST /client/groups/:groupId/task-lists/:taskListId/tasks/:taskId/requirements/:requirementId/progress
|
||||
// Body: { reference_id, completed, unit_requirement_id?, course_requirement_id? }
|
||||
//
|
||||
// After a successful upsert, rebuilds progressMap locally so all blocks
|
||||
// re-render immediately without a full refetch.
|
||||
const updateLessonProgress = useCallback(
|
||||
(groupId, taskListId, taskId, requirementId, {
|
||||
reference_id,
|
||||
completed,
|
||||
unit_requirement_id,
|
||||
course_requirement_id,
|
||||
// These are needed to derive + patch the map locally for unit/course
|
||||
unit_reference_id,
|
||||
course_reference_id,
|
||||
// All sibling lesson rows so we can derive locally without a refetch
|
||||
siblingLessons = [],
|
||||
siblingUnits = [],
|
||||
}) =>
|
||||
request(async () => {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// ── Optimistic lesson update ──────────────────────────────────
|
||||
const lessonKey = `${requirementId}:${reference_id}`;
|
||||
setProgressMap((prev) => ({
|
||||
...prev,
|
||||
[lessonKey]: { completed: !!completed, completed_at: completed ? now : null },
|
||||
}));
|
||||
|
||||
const res = await api.post(
|
||||
`${BASE}/${groupId}/task-lists/${taskListId}/tasks/${taskId}/requirements/${requirementId}/progress`,
|
||||
{
|
||||
reference_id,
|
||||
completed,
|
||||
unit_requirement_id,
|
||||
course_requirement_id,
|
||||
}
|
||||
);
|
||||
|
||||
// ── Derive unit completion locally ────────────────────────────
|
||||
if (unit_requirement_id && unit_reference_id && siblingLessons.length) {
|
||||
// Build updated lesson states (merge optimistic change into siblings)
|
||||
const updatedLessons = siblingLessons.map((l) =>
|
||||
l.reference_id === reference_id
|
||||
? { ...l, completed: !!completed }
|
||||
: l
|
||||
);
|
||||
const unitDone = updatedLessons.every((l) => l.completed);
|
||||
const unitKey = `${unit_requirement_id}:${unit_reference_id}`;
|
||||
|
||||
setProgressMap((prev) => ({
|
||||
...prev,
|
||||
[unitKey]: { completed: unitDone, completed_at: unitDone ? now : null },
|
||||
}));
|
||||
|
||||
// ── Derive course completion locally ──────────────────────
|
||||
if (course_requirement_id && course_reference_id && siblingUnits.length) {
|
||||
const updatedUnits = siblingUnits.map((u) =>
|
||||
u.unit_reference_id === unit_reference_id
|
||||
? { ...u, completed: unitDone }
|
||||
: u
|
||||
);
|
||||
const courseDone = updatedUnits.every((u) => u.completed);
|
||||
const courseKey = `${course_requirement_id}:${course_reference_id}`;
|
||||
|
||||
setProgressMap((prev) => ({
|
||||
...prev,
|
||||
[courseKey]: { completed: courseDone, completed_at: courseDone ? now : null },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return res.data?.data;
|
||||
}),
|
||||
[request]
|
||||
);
|
||||
|
||||
// ── Reset — call when navigating away from a task ─────────────────────────
|
||||
const resetProgress = useCallback(() => {
|
||||
setLinkVisits([]);
|
||||
setProgress([]);
|
||||
setVisitedMap({});
|
||||
setProgressMap({});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TaskProgressContext.Provider value={{
|
||||
// ── Raw state ─────────────────────────────────────────────────────
|
||||
linkVisits, progress,
|
||||
// ── Lookup maps ───────────────────────────────────────────────────
|
||||
visitedMap, progressMap,
|
||||
// ── Convenience checkers ──────────────────────────────────────────
|
||||
isVisited, isCompleted, getProgress,
|
||||
// ── Loading ───────────────────────────────────────────────────────
|
||||
loading,
|
||||
// ── Actions ───────────────────────────────────────────────────────
|
||||
fetchProgress,
|
||||
visitLink,
|
||||
updateLessonProgress,
|
||||
resetProgress,
|
||||
}}>
|
||||
{children}
|
||||
</TaskProgressContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const ClientTiersContext = createContext(null);
|
||||
|
||||
export function ClientTiersProvider({ children }) {
|
||||
// ── My tier
|
||||
const [myTier, setMyTier] = useState(null);
|
||||
const [tierLoading, setTierLoading] = useState(false);
|
||||
|
||||
// ── Tier history
|
||||
const [tierHistory, setTierHistory] = useState([]);
|
||||
const [tierHistoryLoading, setTierHistoryLoading] = useState(false);
|
||||
|
||||
// ── Plans
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [plansLoading, setPlansLoading] = useState(false);
|
||||
|
||||
// ── Checkout
|
||||
const [checkoutLoading, setCheckoutLoading] = useState(false);
|
||||
|
||||
// ── My payments
|
||||
const [payments, setPayments] = useState([]);
|
||||
const [paymentsLoading, setPaymentsLoading] = useState(false);
|
||||
|
||||
// ─── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
const getMyTier = useCallback(async () => {
|
||||
setTierLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/client/tiers/me");
|
||||
setMyTier(data.data ?? null);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load tier.");
|
||||
} finally {
|
||||
setTierLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getMyTierHistory = useCallback(async () => {
|
||||
setTierHistoryLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/client/tiers/me/history");
|
||||
setTierHistory(data.data ?? []);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load tier history.");
|
||||
} finally {
|
||||
setTierHistoryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getPlans = useCallback(async () => {
|
||||
setPlansLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/client/tiers/plans");
|
||||
setPlans(data.data ?? []);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load plans.");
|
||||
} finally {
|
||||
setPlansLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Returns { payment_id, order_id, approval_url, amount, currency, ... } or null
|
||||
const createOrder = useCallback(async (plan_id, promo_code = null) => {
|
||||
setCheckoutLoading(true);
|
||||
try {
|
||||
const payload = { plan_id };
|
||||
if (promo_code) payload.promo_code = promo_code;
|
||||
const { data } = await api.post("/client/tiers/checkout/order", payload);
|
||||
return data.data ?? null;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not create order.");
|
||||
return null;
|
||||
} finally {
|
||||
setCheckoutLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// order_id = PayPal order ID returned from ?token= query param on redirect back
|
||||
const captureOrder = useCallback(async (order_id) => {
|
||||
setCheckoutLoading(true);
|
||||
try {
|
||||
const { data } = await api.post("/client/tiers/checkout/capture", { order_id });
|
||||
toast.success(data.message ?? "Payment successful. Tier activated.");
|
||||
setMyTier({ tier: data.data?.tier, expires_at: data.data?.expires_at, status: "active" });
|
||||
return data.data ?? null;
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Payment capture failed.");
|
||||
return null;
|
||||
} finally {
|
||||
setCheckoutLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cancelOrder = useCallback(async (order_id) => {
|
||||
if (!order_id) return false;
|
||||
try {
|
||||
await api.post("/client/tiers/checkout/cancel", { order_id });
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Silent — cancellation failure shouldn't interrupt UX
|
||||
console.error("[CANCEL ORDER]", err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getMyPayments = useCallback(async () => {
|
||||
setPaymentsLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/client/tiers/me/payments");
|
||||
setPayments(data.data ?? []);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load payment history.");
|
||||
} finally {
|
||||
setPaymentsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ─── Reset helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
const resetMyTier = useCallback(() => setMyTier(null), []);
|
||||
const resetPayments = useCallback(() => setPayments([]), []);
|
||||
|
||||
// ─── Value ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const value = {
|
||||
// state
|
||||
myTier, tierLoading,
|
||||
tierHistory, tierHistoryLoading,
|
||||
plans, plansLoading,
|
||||
checkoutLoading,
|
||||
payments, paymentsLoading,
|
||||
|
||||
// actions
|
||||
getMyTier,
|
||||
getMyTierHistory,
|
||||
getPlans,
|
||||
createOrder,
|
||||
captureOrder,
|
||||
cancelOrder,
|
||||
getMyPayments,
|
||||
|
||||
// resets
|
||||
resetMyTier,
|
||||
resetPayments,
|
||||
};
|
||||
|
||||
return (
|
||||
<ClientTiersContext.Provider value={value}>
|
||||
{children}
|
||||
</ClientTiersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useClientTiers() {
|
||||
const ctx = useContext(ClientTiersContext);
|
||||
if (!ctx) throw new Error("useClientTiers must be used within ClientTiersProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export default ClientTiersContext;
|
||||
@@ -1,82 +1,43 @@
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
const MetadataContext = createContext(null);
|
||||
const APP_NAME = 'STARR | Philproperties'
|
||||
const DEFAULT_DESC = 'This is still in development phase. Come back soon.'
|
||||
const DEFAULT_KW = 'philproperties, online courses, sales training'
|
||||
const DEFAULT_IMG = 'https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC'
|
||||
|
||||
const DEFAULT_METADATA = {
|
||||
title: "STARR | Philproperties",
|
||||
description: "This is still in development phase. Come back soon.",
|
||||
keywords: "philproperties, online courses, sales training",
|
||||
|
||||
// Open Graph
|
||||
ogType: "website",
|
||||
ogImage:
|
||||
"https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC",
|
||||
|
||||
// Twitter
|
||||
twitterCard: "summary_large_image",
|
||||
twitterImage:
|
||||
"https://95306gu5u4.ufs.sh/f/uBRuoG2BEPFD7KSEgHPf2HyENZzXqhfcGpQsYtIMT9F5RWUC",
|
||||
};
|
||||
|
||||
export function MetadataProvider({ children, value }) {
|
||||
const location = useLocation();
|
||||
|
||||
const metadata = useMemo(
|
||||
() => ({ ...DEFAULT_METADATA, ...value }),
|
||||
[value]
|
||||
);
|
||||
|
||||
const url =
|
||||
typeof window !== "undefined"
|
||||
? `${window.location.origin}${location.pathname}`
|
||||
: "";
|
||||
export function PageMeta({ title, description, keywords, ogImage, ogType = 'website' }) {
|
||||
const { pathname } = useLocation()
|
||||
const url = `${window.location.origin}${pathname}`
|
||||
const t = title ?? APP_NAME
|
||||
const desc = description ?? DEFAULT_DESC
|
||||
const kw = keywords ?? DEFAULT_KW
|
||||
const img = ogImage ?? DEFAULT_IMG
|
||||
|
||||
return (
|
||||
<MetadataContext.Provider value={metadata}>
|
||||
<Helmet key={location.pathname}>
|
||||
{/* Basic SEO */}
|
||||
<title>{metadata.title}</title>
|
||||
<meta name="description" content={metadata.description} />
|
||||
<meta name="keywords" content={metadata.keywords} />
|
||||
|
||||
{/* Open Graph */}
|
||||
<meta property="og:title" content={metadata.ogTitle ?? metadata.title} />
|
||||
<meta
|
||||
property="og:description"
|
||||
content={metadata.ogDescription ?? metadata.description}
|
||||
/>
|
||||
<meta property="og:type" content={metadata.ogType} />
|
||||
<meta property="og:url" content={url} />
|
||||
{metadata.ogImage && (
|
||||
<meta property="og:image" content={metadata.ogImage} />
|
||||
)}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content={metadata.twitterCard} />
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content={metadata.twitterTitle ?? metadata.title}
|
||||
/>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content={metadata.twitterDescription ?? metadata.description}
|
||||
/>
|
||||
{metadata.twitterImage && (
|
||||
<meta name="twitter:image" content={metadata.twitterImage} />
|
||||
)}
|
||||
</Helmet>
|
||||
|
||||
{children}
|
||||
</MetadataContext.Provider>
|
||||
);
|
||||
<Helmet>
|
||||
<title>{t}</title>
|
||||
<meta name="description" content={desc} />
|
||||
<meta name="keywords" content={kw} />
|
||||
<meta property="og:title" content={t} />
|
||||
<meta property="og:description" content={desc} />
|
||||
<meta property="og:type" content={ogType} />
|
||||
<meta property="og:url" content={url} />
|
||||
<meta property="og:image" content={img} />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={t} />
|
||||
<meta name="twitter:description" content={desc} />
|
||||
<meta name="twitter:image" content={img} />
|
||||
</Helmet>
|
||||
)
|
||||
}
|
||||
|
||||
export function useMetadata() {
|
||||
const ctx = useContext(MetadataContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useMetadata must be used within MetadataProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
// Backward-compat shim — Login and Register still use this
|
||||
export function MetadataProvider({ children, value = {} }) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta {...value} />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
import api from "@/utils/api.util";
|
||||
import { toast } from "sonner";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
const ProfileContext = createContext(null);
|
||||
|
||||
export function ProfileProvider({ children, apiBase = '/client' }) {
|
||||
const { setUser, user: authUser } = useAuth();
|
||||
|
||||
// ── Profile state
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
|
||||
// ── Sessions state
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||
const [revokingId, setRevokingId] = useState(null);
|
||||
|
||||
// ── Achievements state
|
||||
const [achievements, setAchievements] = useState([]);
|
||||
const [achievementsLoading, setAchievementsLoading] = useState(false);
|
||||
|
||||
// ── Avatar state
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
|
||||
const getProfile = useCallback(async () => {
|
||||
setProfileLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`${apiBase}/profile`);
|
||||
setProfile(data.data ?? null);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load profile.");
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
}, [apiBase]);
|
||||
|
||||
const updateProfile = useCallback(async (personal_info) => {
|
||||
setProfileLoading(true);
|
||||
try {
|
||||
const { data } = await api.put(`${apiBase}/profile`, { personal_info });
|
||||
setProfile(data.data ?? null);
|
||||
setUser((prev) => ({ ...prev, ...data.data }));
|
||||
toast.success(data.message ?? "Profile updated.");
|
||||
return { success: true, data: data.data };
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not update profile.");
|
||||
return { success: false };
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
}, [apiBase, setUser]);
|
||||
|
||||
const getSessions = useCallback(async () => {
|
||||
setSessionsLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`${apiBase}/sessions`);
|
||||
setSessions(data.data ?? []);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load sessions.");
|
||||
} finally {
|
||||
setSessionsLoading(false);
|
||||
}
|
||||
}, [apiBase]);
|
||||
|
||||
const revokeSession = useCallback(async (sessionId) => {
|
||||
setRevokingId(sessionId);
|
||||
try {
|
||||
await api.delete(`${apiBase}/sessions/${sessionId}`);
|
||||
setSessions((prev) => prev.filter((s) => s.session_id !== sessionId));
|
||||
toast.success("Session revoked.");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not revoke session.");
|
||||
return { success: false };
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
}
|
||||
}, [apiBase]);
|
||||
|
||||
const uploadAvatar = useCallback(async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
setAvatarLoading(true);
|
||||
try {
|
||||
const { data } = await api.post(`${apiBase}/profile/avatar`, formData);
|
||||
setProfile(data.data ?? null);
|
||||
setUser((prev) => ({ ...prev, personal_info: data.data?.personal_info }));
|
||||
toast.success('Avatar updated.');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? 'Could not update avatar.');
|
||||
return { success: false };
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
}
|
||||
}, [apiBase, setUser]);
|
||||
|
||||
const deleteAvatar = useCallback(async () => {
|
||||
setAvatarLoading(true);
|
||||
try {
|
||||
await api.delete(`${apiBase}/profile/avatar`);
|
||||
setProfile((prev) => prev
|
||||
? { ...prev, personal_info: { ...(prev.personal_info ?? {}), avatar: null } }
|
||||
: prev
|
||||
);
|
||||
setUser((prev) => ({
|
||||
...prev,
|
||||
personal_info: { ...(prev?.personal_info ?? {}), avatar: null },
|
||||
}));
|
||||
toast.success('Avatar removed.');
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? 'Could not remove avatar.');
|
||||
return { success: false };
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
}
|
||||
}, [apiBase, setUser]);
|
||||
|
||||
const getAchievements = useCallback(async () => {
|
||||
setAchievementsLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`${apiBase}/achievements`);
|
||||
setAchievements(data.data ?? []);
|
||||
} catch (err) {
|
||||
toast.error(err?.response?.data?.message ?? "Could not load achievements.");
|
||||
} finally {
|
||||
setAchievementsLoading(false);
|
||||
}
|
||||
}, [apiBase]);
|
||||
|
||||
// ─── Derived helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const pi = profile?.personal_info ?? authUser?.personal_info;
|
||||
|
||||
const fullName = pi?.name?.full_name ?? "";
|
||||
const givenName = pi?.name?.given_name ?? "";
|
||||
const lastName = pi?.name?.last_name ?? "";
|
||||
const avatarUrl = pi?.avatar?.url ?? "";
|
||||
const occupation = pi?.occupation ?? "";
|
||||
|
||||
// ─── Reset helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
const resetProfile = useCallback(() => setProfile(null), []);
|
||||
const resetSessions = useCallback(() => setSessions([]), []);
|
||||
const resetAchievements = useCallback(() => setAchievements([]), []);
|
||||
|
||||
// ─── Value ────────────────────────────────────────────────────────────────
|
||||
|
||||
const value = {
|
||||
// state
|
||||
profile, profileLoading,
|
||||
sessions, sessionsLoading, revokingId,
|
||||
achievements, achievementsLoading,
|
||||
|
||||
// derived
|
||||
fullName, givenName, lastName, avatarUrl, occupation,
|
||||
|
||||
// avatar
|
||||
avatarLoading,
|
||||
uploadAvatar, deleteAvatar,
|
||||
|
||||
// actions
|
||||
getProfile, updateProfile,
|
||||
getSessions, revokeSession,
|
||||
getAchievements,
|
||||
|
||||
// resets
|
||||
resetProfile, resetSessions, resetAchievements,
|
||||
};
|
||||
|
||||
return (
|
||||
<ProfileContext.Provider value={value}>
|
||||
{children}
|
||||
</ProfileContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useProfile() {
|
||||
const ctx = useContext(ProfileContext);
|
||||
if (!ctx) throw new Error("useProfile must be used within ProfileProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export default ProfileContext;
|
||||
@@ -1,39 +1,65 @@
|
||||
/**
|
||||
* ╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
* ║ ThemeContext.jsx ║
|
||||
* ╠══════════════════════════════════════════════════════════════════════════════╣
|
||||
* ║ Author : rgrgogu ║
|
||||
* ║ Date Created : May 5, 2026 ║
|
||||
* ║ Date Modified : May 24, 2026 (by Kenneth Obsequio @lash0000) ║
|
||||
* ╠══════════════════════════════════════════════════════════════════════════════╣
|
||||
* ║ Changelog ║
|
||||
* ║ - Added: 'system' theme support ║
|
||||
* ╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
const ThemeProviderContext = createContext({
|
||||
theme: 'light',
|
||||
theme: 'system',
|
||||
toggleTheme: () => null,
|
||||
setTheme: () => null,
|
||||
});
|
||||
|
||||
function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'light',
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'vite-ui-theme',
|
||||
}) {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
const savedTheme = localStorage.getItem(storageKey) || defaultTheme;
|
||||
return savedTheme;
|
||||
const [theme, setThemeState] = useState(() => {
|
||||
return localStorage.getItem(storageKey) || defaultTheme;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
const applied = theme === 'system' ? getSystemTheme() : theme;
|
||||
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
root.classList.add(applied);
|
||||
localStorage.setItem(storageKey, theme);
|
||||
|
||||
if (theme === 'system') {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e) => {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(e.matches ? 'dark' : 'light');
|
||||
};
|
||||
media.addEventListener('change', handler);
|
||||
return () => media.removeEventListener('change', handler);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
toggleTheme: () => {
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light';
|
||||
localStorage.setItem(storageKey, newTheme);
|
||||
setTheme(newTheme);
|
||||
setThemeState(newTheme);
|
||||
},
|
||||
setTheme: (newTheme) => {
|
||||
if (['light', 'dark'].includes(newTheme)) {
|
||||
localStorage.setItem(storageKey, newTheme);
|
||||
setTheme(newTheme);
|
||||
if (['light', 'dark', 'system'].includes(newTheme)) {
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -51,4 +77,4 @@ export function useTheme() {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,39 @@ import { UserProvider } from "../AdminUserContext";
|
||||
import { UserGroupProvider } from "../AdminUserGroupContext";
|
||||
import { CoursesProvider } from "../AdminCoursesContext";
|
||||
import { AdminTaskProvider } from "../AdminTaskContext";
|
||||
import { AdminTiersProvider } from "../AdminTiersContext";
|
||||
import { AdminCategoriesProvider } from "../AdminCategoriesContext";
|
||||
import { ProfileProvider } from "../ProfileProvider";
|
||||
import { AdvertisementsProvider } from "../AdminAdvertisementContext";
|
||||
import { AdminNotificationProvider } from "../AdminNotificationContext"
|
||||
import { AdminCourseReadingProgressProvider } from "../AdminCourseReadingProgressContext";
|
||||
|
||||
export const AdminProvider = ({ children }) => {
|
||||
return (
|
||||
<AdminDashboardProvider>
|
||||
<AssetsProvider>
|
||||
<UserProvider>
|
||||
<UserGroupProvider>
|
||||
<CoursesProvider>
|
||||
<AdminTaskProvider>
|
||||
{children}
|
||||
</AdminTaskProvider>
|
||||
</CoursesProvider>
|
||||
</UserGroupProvider>
|
||||
</UserProvider>
|
||||
</AssetsProvider>
|
||||
</AdminDashboardProvider>
|
||||
<AdminNotificationProvider>
|
||||
<AdminDashboardProvider>
|
||||
<ProfileProvider apiBase="/admin">
|
||||
<AssetsProvider>
|
||||
<AdvertisementsProvider>
|
||||
<UserProvider>
|
||||
<UserGroupProvider>
|
||||
<AdminTiersProvider>
|
||||
<AdminCategoriesProvider>
|
||||
<CoursesProvider>
|
||||
<AdminCourseReadingProgressProvider>
|
||||
<AdminTaskProvider>
|
||||
{children}
|
||||
</AdminTaskProvider>
|
||||
</AdminCourseReadingProgressProvider>
|
||||
</CoursesProvider>
|
||||
</AdminCategoriesProvider>
|
||||
</AdminTiersProvider>
|
||||
</UserGroupProvider>
|
||||
</UserProvider>
|
||||
</AdvertisementsProvider>
|
||||
</AssetsProvider>
|
||||
</ProfileProvider>
|
||||
</AdminDashboardProvider>
|
||||
</AdminNotificationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ClientCoursesProvider } from "../ClientCoursesContext"
|
||||
import { ClientTiersProvider } from "../ClientTiersProvider"
|
||||
import { ProfileProvider } from "../ProfileProvider"
|
||||
import { TaskProgressProvider } from "../ClientTaskProgressContext"
|
||||
import { TaskProvider } from "../ClientTaskContext"
|
||||
import { GroupProvider } from "../ClientGroupContext"
|
||||
import { ClientAdvertisementsProvider } from "../ClientAdvertisementContext"
|
||||
import { ClientNotificationProvider } from "../ClientNotificationContext"
|
||||
import { CourseReadingProgressProvider } from "../ClientCourseReadingProgressContext"
|
||||
|
||||
|
||||
export const ClientProvider = ({ children }) => {
|
||||
return (
|
||||
<ClientNotificationProvider>
|
||||
<ProfileProvider>
|
||||
<ClientAdvertisementsProvider>
|
||||
<ClientCoursesProvider>
|
||||
<CourseReadingProgressProvider>
|
||||
<ClientTiersProvider>
|
||||
<TaskProgressProvider>
|
||||
<TaskProvider>
|
||||
<GroupProvider>
|
||||
{children}
|
||||
</GroupProvider>
|
||||
</TaskProvider>
|
||||
</TaskProgressProvider>
|
||||
</ClientTiersProvider>
|
||||
</CourseReadingProgressProvider>
|
||||
</ClientCoursesProvider>
|
||||
</ClientAdvertisementsProvider>
|
||||
</ProfileProvider>
|
||||
</ClientNotificationProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Action registry — label + badge colour per action key
|
||||
|
||||
export const ACTION_CONFIG = {
|
||||
// ── Auth ────────────────────────────────────────────────────────────────────
|
||||
login: { label: "Logged In", group: "auth" },
|
||||
logout: { label: "Logged Out", group: "auth" },
|
||||
register: { label: "Registered", group: "auth" },
|
||||
password_change: { label: "Changed Password", group: "auth" },
|
||||
|
||||
// ── Profile ─────────────────────────────────────────────────────────────────
|
||||
update_profile: { label: "Updated Profile", group: "profile" },
|
||||
revoke_session: { label: "Revoked Session", group: "profile" },
|
||||
|
||||
// ── Learning ────────────────────────────────────────────────────────────────
|
||||
lesson_read: { label: "Read Lesson", group: "learn" },
|
||||
submit_task: { label: "Submitted Task", group: "learn" },
|
||||
visit_link: { label: "Visited Link", group: "learn" },
|
||||
|
||||
// ── User management ─────────────────────────────────────────────────────────
|
||||
create_staff: { label: "Created Staff", group: "mgmt" },
|
||||
update_user: { label: "Updated User", group: "mgmt" },
|
||||
deactivate_user: { label: "Deactivated User", group: "danger" },
|
||||
restore_user: { label: "Restored User", group: "success" },
|
||||
terminate_session: { label: "Terminated Session", group: "danger" },
|
||||
set_user_status: { label: "Set User Status", group: "mgmt" },
|
||||
|
||||
// ── Courses ─────────────────────────────────────────────────────────────────
|
||||
create_course: { label: "Created Course", group: "course" },
|
||||
update_course: { label: "Updated Course", group: "course" },
|
||||
archive_course: { label: "Archived Course", group: "course" },
|
||||
restore_course: { label: "Restored Course", group: "course" },
|
||||
bulk_archive_courses: { label: "Bulk Archived Courses", group: "course" },
|
||||
bulk_restore_courses: { label: "Bulk Restored Courses", group: "course" },
|
||||
sync_prerequisites: { label: "Synced Prerequisites", group: "course" },
|
||||
sync_instructors: { label: "Synced Instructors", group: "course" },
|
||||
|
||||
// ── Units ───────────────────────────────────────────────────────────────────
|
||||
create_unit: { label: "Created Unit", group: "course" },
|
||||
update_unit: { label: "Updated Unit", group: "course" },
|
||||
archive_unit: { label: "Archived Unit", group: "course" },
|
||||
restore_unit: { label: "Restored Unit", group: "course" },
|
||||
bulk_archive_units: { label: "Bulk Archived Units", group: "course" },
|
||||
bulk_restore_units: { label: "Bulk Restored Units", group: "course" },
|
||||
|
||||
// ── Lessons ─────────────────────────────────────────────────────────────────
|
||||
create_lesson: { label: "Created Lesson", group: "course" },
|
||||
update_lesson: { label: "Updated Lesson", group: "course" },
|
||||
archive_lesson: { label: "Archived Lesson", group: "course" },
|
||||
restore_lesson: { label: "Restored Lesson", group: "course" },
|
||||
bulk_archive_lessons: { label: "Bulk Archived Lessons", group: "course" },
|
||||
bulk_restore_lessons: { label: "Bulk Restored Lessons", group: "course" },
|
||||
upsert_lesson_page: { label: "Updated Lesson Page", group: "course" },
|
||||
|
||||
// ── Quizzes ─────────────────────────────────────────────────────────────────
|
||||
create_quiz: { label: "Created Quiz", group: "course" },
|
||||
update_quiz: { label: "Updated Quiz", group: "course" },
|
||||
archive_quiz: { label: "Archived Quiz", group: "course" },
|
||||
restore_quiz: { label: "Restored Quiz", group: "course" },
|
||||
|
||||
// ── Questions ───────────────────────────────────────────────────────────────
|
||||
create_question: { label: "Created Question", group: "course" },
|
||||
update_question: { label: "Updated Question", group: "course" },
|
||||
archive_question: { label: "Archived Question", group: "course" },
|
||||
restore_question: { label: "Restored Question", group: "course" },
|
||||
bulk_archive_questions: { label: "Bulk Archived Questions", group: "course" },
|
||||
bulk_restore_questions: { label: "Bulk Restored Questions", group: "course" },
|
||||
|
||||
// ── Assessments ─────────────────────────────────────────────────────────────
|
||||
create_assessment: { label: "Created Assessment", group: "course" },
|
||||
update_assessment: { label: "Updated Assessment", group: "course" },
|
||||
archive_assessment: { label: "Archived Assessment", group: "course" },
|
||||
restore_assessment: { label: "Restored Assessment", group: "course" },
|
||||
|
||||
// ── Task Lists ──────────────────────────────────────────────────────────────
|
||||
create_task_list: { label: "Created Task List", group: "task" },
|
||||
update_task_list: { label: "Updated Task List", group: "task" },
|
||||
archive_task_list: { label: "Archived Task List", group: "task" },
|
||||
restore_task_list: { label: "Restored Task List", group: "task" },
|
||||
bulk_archive_task_lists: { label: "Bulk Archived Task Lists", group: "task" },
|
||||
bulk_restore_task_lists: { label: "Bulk Restored Task Lists", group: "task" },
|
||||
assign_groups: { label: "Assigned Groups", group: "task" },
|
||||
unassign_groups: { label: "Unassigned Groups", group: "task" },
|
||||
|
||||
// ── Tasks ───────────────────────────────────────────────────────────────────
|
||||
create_task: { label: "Created Task", group: "task" },
|
||||
update_task: { label: "Updated Task", group: "task" },
|
||||
archive_task: { label: "Archived Task", group: "task" },
|
||||
restore_task: { label: "Restored Task", group: "task" },
|
||||
bulk_archive_tasks: { label: "Bulk Archived Tasks", group: "task" },
|
||||
bulk_restore_tasks: { label: "Bulk Restored Tasks", group: "task" },
|
||||
|
||||
// ── Task Completions ────────────────────────────────────────────────────────
|
||||
archive_completion: { label: "Archived Completion", group: "task" },
|
||||
restore_completion: { label: "Restored Completion", group: "task" },
|
||||
bulk_archive_completions: { label: "Bulk Archived Completions", group: "task" },
|
||||
bulk_restore_completions: { label: "Bulk Restored Completions", group: "task" },
|
||||
|
||||
// ── Groups ──────────────────────────────────────────────────────────────────
|
||||
create_group: { label: "Created Group", group: "grp" },
|
||||
update_group: { label: "Updated Group", group: "grp" },
|
||||
deactivate_group: { label: "Deactivated Group", group: "grp" },
|
||||
restore_group: { label: "Restored Group", group: "grp" },
|
||||
bulk_deactivate_groups:{ label: "Bulk Deactivated Groups", group: "grp" },
|
||||
bulk_restore_groups: { label: "Bulk Restored Groups", group: "grp" },
|
||||
add_user_to_group: { label: "Added User to Group", group: "grp" },
|
||||
remove_user_from_group:{ label: "Removed User from Group", group: "grp" },
|
||||
|
||||
// ── Tier Plans ──────────────────────────────────────────────────────────────
|
||||
create_tier_plan: { label: "Created Tier Plan", group: "commerce" },
|
||||
update_tier_plan: { label: "Updated Tier Plan", group: "commerce" },
|
||||
archive_tier_plan: { label: "Archived Tier Plan", group: "commerce" },
|
||||
restore_tier_plan: { label: "Restored Tier Plan", group: "commerce" },
|
||||
bulk_archive_tier_plans: { label: "Bulk Archived Tier Plans", group: "commerce" },
|
||||
bulk_restore_tier_plans: { label: "Bulk Restored Tier Plans", group: "commerce" },
|
||||
sync_plan_courses: { label: "Synced Plan Courses", group: "commerce" },
|
||||
grant_tier: { label: "Granted Tier", group: "success" },
|
||||
revoke_tier: { label: "Revoked Tier", group: "danger" },
|
||||
|
||||
// ── Products & Categories ───────────────────────────────────────────────────
|
||||
upsert_course_product: { label: "Set Course Product", group: "commerce" },
|
||||
remove_course_product: { label: "Removed Course Product", group: "commerce" },
|
||||
sync_course_categories: { label: "Synced Course Categories", group: "commerce" },
|
||||
create_category: { label: "Created Category", group: "commerce" },
|
||||
update_category: { label: "Updated Category", group: "commerce" },
|
||||
archive_category: { label: "Archived Category", group: "commerce" },
|
||||
restore_category: { label: "Restored Category", group: "commerce" },
|
||||
|
||||
// ── Assets ──────────────────────────────────────────────────────────────────
|
||||
upload_asset: { label: "Uploaded Asset", group: "content" },
|
||||
update_asset: { label: "Updated Asset", group: "content" },
|
||||
archive_asset: { label: "Archived Asset", group: "content" },
|
||||
restore_asset: { label: "Restored Asset", group: "content" },
|
||||
bulk_archive_assets:{ label: "Bulk Archived Assets", group: "content" },
|
||||
bulk_restore_assets:{ label: "Bulk Restored Assets", group: "content" },
|
||||
|
||||
// ── Advertisements ──────────────────────────────────────────────────────────
|
||||
create_advertisement: { label: "Created Ad", group: "content" },
|
||||
update_advertisement: { label: "Updated Ad", group: "content" },
|
||||
archive_advertisement: { label: "Archived Ad", group: "content" },
|
||||
restore_advertisement: { label: "Restored Ad", group: "content" },
|
||||
bulk_archive_advertisements: { label: "Bulk Archived Ads", group: "content" },
|
||||
bulk_restore_advertisements: { label: "Bulk Restored Ads", group: "content" },
|
||||
};
|
||||
|
||||
// Badge class per group
|
||||
const GROUP_STYLES = {
|
||||
auth: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-800",
|
||||
profile: "bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300 dark:border-violet-800",
|
||||
learn: "bg-teal-50 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-800",
|
||||
mgmt: "bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-800",
|
||||
danger: "bg-red-50 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800",
|
||||
success: "bg-green-50 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-800",
|
||||
course: "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-800",
|
||||
task: "bg-indigo-50 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-800",
|
||||
grp: "bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300 dark:border-sky-800",
|
||||
commerce: "bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-950 dark:text-rose-300 dark:border-rose-800",
|
||||
content: "bg-slate-100 text-slate-600 border-slate-200 dark:bg-slate-800 dark:text-slate-400 dark:border-slate-700",
|
||||
};
|
||||
|
||||
const FALLBACK = "bg-muted text-muted-foreground border-border";
|
||||
|
||||
export function getActionBadge(action) {
|
||||
const cfg = ACTION_CONFIG[action];
|
||||
return {
|
||||
label: cfg?.label ?? action,
|
||||
className: cfg ? (GROUP_STYLES[cfg.group] ?? FALLBACK) : FALLBACK,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Users, GitFork, FolderOpen, BookText, ListCheck } from "lucide-react";
|
||||
import { Users, GitFork, FolderOpen, BookText, ListCheck, ShieldCheck, Megaphone } from "lucide-react";
|
||||
|
||||
export const ADMIN_SECTIONS = [
|
||||
{
|
||||
@@ -9,6 +9,7 @@ export const ADMIN_SECTIONS = [
|
||||
tiles: [
|
||||
{ key: "users", label: "Users", icon: Users, link: "/admin/users" },
|
||||
{ key: "user-groups", label: "User Groups", icon: GitFork, link: "/admin/groups" },
|
||||
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -28,6 +29,7 @@ export const ADMIN_SECTIONS = [
|
||||
tiles: [
|
||||
{ key: "courses", label: "Courses", icon: BookText, link: "/admin/courses" },
|
||||
{ key: "tasks", label: "Tasks", icon: ListCheck, link: "/admin/taskList" },
|
||||
{ key: "tiers", label: "Tier Plans", icon: ShieldCheck, link: "/admin/tiers/plans" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -35,6 +37,8 @@ export const ADMIN_SECTIONS = [
|
||||
tab: "Site Content",
|
||||
title: "Site Content",
|
||||
description: "Manage public-facing content",
|
||||
tiles: [],
|
||||
tiles: [
|
||||
{ key: "advertisements", label: "Advertisements", icon: Megaphone, link: "/admin/advertisements" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,39 @@
|
||||
// data/advertisements.data.js
|
||||
import { Megaphone, Image, BellRing, PanelRight } from "lucide-react";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
// Drives: filter dropdown options, type badge label/icon on each card,
|
||||
// and which fields the Add/Edit form shows (hero needs headline/description/ctas,
|
||||
// banner/popup/sidebar are closer to image-only).
|
||||
|
||||
export const ADVERTISEMENT_TYPES = [
|
||||
{ value: "hero", label: "Hero", icon: Megaphone, description: "Large featured banner with headline, description, and CTAs" },
|
||||
{ value: "banner", label: "Banner", icon: Image, description: "Simple image banner" },
|
||||
{ value: "popup", label: "Popup", icon: BellRing, description: "Modal-style popup shown on page load" },
|
||||
{ value: "sidebar", label: "Sidebar", icon: PanelRight, description: "Compact image placed in a sidebar slot" },
|
||||
];
|
||||
|
||||
export const ADVERTISEMENT_TYPE_MAP = Object.fromEntries(
|
||||
ADVERTISEMENT_TYPES.map((t) => [t.value, t])
|
||||
);
|
||||
|
||||
// Types that show the rich content fields (headline, description, CTAs) in the form
|
||||
export const RICH_CONTENT_TYPES = ["hero"];
|
||||
|
||||
// ─── Statuses ───────────────────────────────────────────────────────────────
|
||||
// Drives: filter dropdown options, status badge color/label on each card.
|
||||
|
||||
export const ADVERTISEMENT_STATUSES = [
|
||||
{ value: "draft", label: "Draft", badgeClass: "bg-muted text-muted-foreground" },
|
||||
{ value: "active", label: "Active", badgeClass: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400" },
|
||||
{ value: "scheduled", label: "Scheduled", badgeClass: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400" },
|
||||
{ value: "expired", label: "Expired", badgeClass: "bg-muted text-muted-foreground" },
|
||||
{ value: "archived", label: "Archived", badgeClass: "bg-muted text-muted-foreground" },
|
||||
];
|
||||
|
||||
export const ADVERTISEMENT_STATUS_MAP = Object.fromEntries(
|
||||
ADVERTISEMENT_STATUSES.map((s) => [s.value, s])
|
||||
);
|
||||
|
||||
// Max number of CTAs allowed per advertisement (matches backend normalizeCtas slice(0,2))
|
||||
export const MAX_CTAS = 2;
|
||||
@@ -1,31 +1,9 @@
|
||||
// ─── Mock data — replace with real API calls ──────────────────────────────────
|
||||
|
||||
export const MOCK_ACTIVITIES = [
|
||||
{ id: 1, label: 'Completed Module 3: Advanced React', date: 'May 1, 2026', type: 'module' },
|
||||
{ id: 2, label: 'Attended Live Session: Node.js Basics', date: 'Apr 28, 2026', type: 'session' },
|
||||
{ id: 3, label: 'Submitted Assignment: API Design', date: 'Apr 25, 2026', type: 'assignment' },
|
||||
{ id: 4, label: 'Completed Quiz: JavaScript Fundamentals', date: 'Apr 20, 2026', type: 'quiz' },
|
||||
]
|
||||
|
||||
export const MOCK_ACHIEVEMENTS = [
|
||||
{ id: 1, title: 'Fast Learner', desc: 'Completed 5 modules in one week', icon: '⚡', date: 'Apr 2026' },
|
||||
{ id: 2, title: 'Perfect Score', desc: 'Scored 100% on a quiz', icon: '🎯', date: 'Mar 2026' },
|
||||
{ id: 3, title: 'Consistent', desc: 'Logged in 7 days in a row', icon: '🔥', date: 'Feb 2026' },
|
||||
{ id: 4, title: 'First Submission', desc: 'Submitted your first assignment', icon: '📝', date: 'Jan 2026' },
|
||||
]
|
||||
|
||||
// ─── Config ───────────────────────────────────────────────────────────────────
|
||||
export const ROLE_CONFIG = {
|
||||
admin: { label: 'Administrator', variant: 'destructive' },
|
||||
staff: { label: 'Staff', variant: 'secondary' },
|
||||
client: { label: 'Client', variant: 'default' },
|
||||
}
|
||||
|
||||
export const ACTIVITY_VARIANTS = {
|
||||
module: 'default',
|
||||
session: 'secondary',
|
||||
assignment: 'outline',
|
||||
quiz: 'destructive',
|
||||
admin: { label: 'Administrator', variant: 'destructive' },
|
||||
staff: { label: 'Staff', variant: 'secondary' },
|
||||
client: { label: 'Client', variant: 'default' },
|
||||
user: { label: 'Client', variant: 'default' }, // ← add this
|
||||
}
|
||||
|
||||
export const AVATAR_COLORS = {
|
||||
|
||||
+17
-22
@@ -1,24 +1,26 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name: index.css
|
||||
* Type of Program: Cascade Style Sheet
|
||||
* Description: A set of rules provided by shadcn during project creation.
|
||||
* Module: Global Styles
|
||||
* Author: lash0000
|
||||
* Date Created: Oct. 10, 2025
|
||||
***********************************************************************************************************************************************************************
|
||||
* Change History:
|
||||
* DATE AUTHOR LOG NUMBER DESCRIPTION
|
||||
* Oct. 10, 2025 lash0000 001 Initial creation - STAR Phase 1 Project
|
||||
***********************************************************************************************************************************************************************/
|
||||
/**
|
||||
* ╔══════════════════════════════════════════════════════════════════════════════╗
|
||||
* ║ index.css ║
|
||||
* ╠══════════════════════════════════════════════════════════════════════════════╣
|
||||
* ║ Author : rgrgogu ║
|
||||
* ║ Date Created : Oct. 10, 2025 ║
|
||||
* ║ Date Modified : May 31, 2026 (by Kenneth Obsequio) ║
|
||||
* ╠══════════════════════════════════════════════════════════════════════════════╣
|
||||
* ║ Changelog ║
|
||||
* ║ - Added: 'system' theme support ║
|
||||
* ║ - Added: @import "shadcn/tailwind.css" support from docs ║
|
||||
* ╚══════════════════════════════════════════════════════════════════════════════╝
|
||||
*/
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
/* From myself */
|
||||
--font-geist: Geist, sans-serif;
|
||||
--font-geist: "Instrument Sans", sans-serif;
|
||||
--breakpoint-xs: 320px;
|
||||
|
||||
/* white fixed */
|
||||
@@ -125,19 +127,12 @@
|
||||
}
|
||||
|
||||
--animate-aurora: aurora 60s linear infinite;
|
||||
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
|
||||
--color-warning: var(--warning);
|
||||
|
||||
--color-success-foreground: var(--success-foreground);
|
||||
|
||||
--color-success: var(--success);
|
||||
|
||||
--color-info-foreground: var(--info-foreground);
|
||||
|
||||
--color-info: var(--info);
|
||||
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
@keyframes aurora {
|
||||
@@ -152,7 +147,6 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--navbar-h: 67px; /* match your actual navbar height */
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
@@ -239,9 +233,10 @@
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
font-family: var(--font-geist);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,26 +5,26 @@ 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 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 { 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 [archiveIds, setArchiveIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => {},
|
||||
setFilters: () => {},
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => { },
|
||||
setFilters: () => { },
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
@@ -39,38 +39,39 @@ export default function AssetsTable() {
|
||||
};
|
||||
|
||||
const exportConfig = {
|
||||
allData: assets,
|
||||
allData: assets,
|
||||
attributes,
|
||||
filename: `${getTimestamp()}_Assets`,
|
||||
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 "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}`;
|
||||
case "audio": return `view/audio/${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}`),
|
||||
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(),
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onArchiveMany: (ids) => setArchiveIds(ids),
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onArchiveMany: (ids) => setArchiveIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { UserPlus, Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
|
||||
const ROLE_COLORS = {
|
||||
admin: "bg-red-100 text-red-700 border-red-200",
|
||||
staff: "bg-green-100 text-green-700 border-green-200",
|
||||
};
|
||||
|
||||
function getFullName(u) {
|
||||
return u?.personal_info?.name?.full_name?.trim() || u?.email || "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* added — current instructor list [{ user_id, display_name, ... }]
|
||||
* onAdd(inst) — called with { user_id, display_name } to add one instructor
|
||||
* onToggleLinked(user_id) — remove a linked user by their user_id
|
||||
*/
|
||||
export default function CourseInstructorPicker({ added = [], onAdd, onToggleLinked }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
const [externalName, setExternalName] = useState("");
|
||||
|
||||
const linkedIds = new Set(added.filter(i => i.user_id).map(i => String(i.user_id)));
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
if (users.length) return;
|
||||
setLoadingUsers(true);
|
||||
try {
|
||||
const { data } = await api.get("/admin/users", {
|
||||
params: { limit: 200 },
|
||||
});
|
||||
const rows = data?.data?.data ?? [];
|
||||
setUsers(rows.filter(u => u.acc_type === "staff" || u.acc_type === "admin"));
|
||||
} catch {
|
||||
// silently ignore — user can still add external
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
}
|
||||
}, [users.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadUsers();
|
||||
}, [open, loadUsers]);
|
||||
|
||||
const toggleUser = (u) => {
|
||||
const uid = String(u.user_id);
|
||||
if (linkedIds.has(uid)) {
|
||||
onToggleLinked(u.user_id);
|
||||
} else {
|
||||
onAdd({ user_id: u.user_id, display_name: getFullName(u) });
|
||||
}
|
||||
};
|
||||
|
||||
const addExternal = () => {
|
||||
const name = externalName.trim();
|
||||
if (!name) return;
|
||||
onAdd({ user_id: null, display_name: name });
|
||||
setExternalName("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild className="w-full">
|
||||
<Button type="button" variant="outline" size="sm" className="w-full justify-between">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
Add Instructor
|
||||
</span>
|
||||
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search by name or email…" />
|
||||
<CommandList>
|
||||
|
||||
{/* ── Staff / Admin accounts ── */}
|
||||
<CommandGroup heading="Staff & Admin Accounts">
|
||||
{loadingUsers && (
|
||||
<CommandItem disabled>
|
||||
<Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" />
|
||||
Loading users…
|
||||
</CommandItem>
|
||||
)}
|
||||
{!loadingUsers && users.length === 0 && (
|
||||
<CommandEmpty>No staff or admin accounts found.</CommandEmpty>
|
||||
)}
|
||||
{users.map((u) => {
|
||||
const uid = String(u.user_id);
|
||||
const checked = linkedIds.has(uid);
|
||||
const name = getFullName(u);
|
||||
return (
|
||||
<CommandItem
|
||||
key={uid}
|
||||
value={`${name} ${u.email}`}
|
||||
onSelect={() => toggleUser(u)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Checkbox checked={checked} className="pointer-events-none" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{u.email}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] shrink-0 ${ROLE_COLORS[u.acc_type] ?? ""}`}
|
||||
>
|
||||
{u.acc_type}
|
||||
</Badge>
|
||||
{checked && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
{/* ── External (no account) ── */}
|
||||
<CommandGroup heading="External (no account)">
|
||||
<div className="px-2 py-1.5 flex gap-2">
|
||||
<Input
|
||||
placeholder="Display name"
|
||||
value={externalName}
|
||||
onChange={(e) => setExternalName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addExternal())}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 px-3 shrink-0"
|
||||
disabled={!externalName.trim()}
|
||||
onClick={addExternal}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import {
|
||||
CheckCircle2, Circle, BookOpen, Users, Search, ChevronLeft, ChevronRight, RefreshCcw
|
||||
} from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Pagination, PaginationContent, PaginationItem,
|
||||
PaginationLink, PaginationPrevious, PaginationNext, PaginationEllipsis,
|
||||
} from '@/components/ui/pagination';
|
||||
import { useAdminCourseReadingProgress } from '@/contexts/AdminCourseReadingProgressContext';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatusBadge({ status }) {
|
||||
if (!status) return <Badge variant="outline" className="text-muted-foreground text-xs">Not started</Badge>;
|
||||
return status === 'completed'
|
||||
? <Badge variant="outline" className="text-emerald-600 border-emerald-400 text-xs">Completed</Badge>
|
||||
: <Badge><RefreshCcw />In Progress</Badge>;
|
||||
}
|
||||
|
||||
function ProgressBar({ value, total, className = '' }) {
|
||||
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{value} / {total}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserAvatar({ name, email, avatarUrl }) {
|
||||
const initials = name
|
||||
? name.split(' ').map((n) => n[0]).slice(0, 2).join('').toUpperCase()
|
||||
: (email?.[0] ?? '?').toUpperCase();
|
||||
return (
|
||||
<Avatar className="size-9 shrink-0">
|
||||
<AvatarImage src={avatarUrl ?? undefined} alt={name ?? email} />
|
||||
<AvatarFallback className="text-xs font-semibold bg-secondary text-secondary-foreground">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dialog: full breakdown for one user ─────────────────────────────────────
|
||||
|
||||
function UserDetailDialog({ open, onOpenChange, entry, courseId }) {
|
||||
const { detailCache, detailLoading, fetchUserReadingProgress } = useAdminCourseReadingProgress();
|
||||
const breakdown = entry ? detailCache[entry.user_id] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && entry && !breakdown) {
|
||||
fetchUserReadingProgress(courseId, entry.user_id);
|
||||
}
|
||||
}, [open, entry]);
|
||||
|
||||
const lastSeen = entry?.last_accessed_at
|
||||
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg flex flex-col max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
{entry && <UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />}
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="truncate">
|
||||
{entry?.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="truncate">{entry?.user.email}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* ── Meta strip ── */}
|
||||
{entry && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<StatusBadge status={entry.course_status} />
|
||||
<span className="text-xs">Last seen {lastSeen}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{entry.lessons_completed} / {entry.lessons_total} lessons
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Progress bar ── */}
|
||||
{entry && (
|
||||
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Unit / lesson breakdown — fills remaining height and scrolls ── */}
|
||||
<ScrollArea className="flex-1 min-h-0 pr-2">
|
||||
{detailLoading && !breakdown ? (
|
||||
<div className="space-y-3 py-1">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-36 ml-5" />
|
||||
<Skeleton className="h-3 w-40 ml-5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : breakdown?.length ? (
|
||||
<div className="space-y-4 py-1">
|
||||
{breakdown.map((unit, ui) => (
|
||||
<div key={unit.unit_id} className="space-y-2">
|
||||
{/* Unit header */}
|
||||
<div className="flex items-center gap-2">
|
||||
{unit.status === 'completed'
|
||||
? <CheckCircle2 className="size-4 text-emerald-500 shrink-0" />
|
||||
: unit.status === 'in_progress'
|
||||
? <Circle className="size-4 text-amber-400 shrink-0" />
|
||||
: <Circle className="size-4 text-muted-foreground/30 shrink-0" />
|
||||
}
|
||||
<span className="text-sm font-semibold truncate flex-1">
|
||||
Unit {ui + 1}: {unit.title}
|
||||
</span>
|
||||
{unit.status && <StatusBadge status={unit.status} />}
|
||||
</div>
|
||||
|
||||
{/* Lesson rows */}
|
||||
<div className="ml-6 space-y-1.5 border-l pl-3">
|
||||
{unit.lessons.map((lesson) => (
|
||||
<div key={lesson.lesson_id} className="flex items-center gap-2">
|
||||
{lesson.status === 'completed'
|
||||
? <CheckCircle2 className="size-3 text-emerald-500 shrink-0" />
|
||||
: lesson.status === 'in_progress'
|
||||
? <Circle className="size-3 text-amber-400 shrink-0" />
|
||||
: <Circle className="size-3 text-muted-foreground/25 shrink-0" />
|
||||
}
|
||||
<span className={`text-xs truncate flex-1 ${lesson.status ? 'text-foreground' : 'text-muted-foreground'}`}>
|
||||
{lesson.title}
|
||||
</span>
|
||||
{lesson.status === 'completed' && lesson.completed_at && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0">
|
||||
{new Date(lesson.completed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No lesson data available.</p>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter showCloseButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── User summary card ────────────────────────────────────────────────────────
|
||||
|
||||
function UserCard({ entry, onOpen }) {
|
||||
const lastSeen = entry.last_accessed_at
|
||||
? new Date(entry.last_accessed_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(entry)}
|
||||
className="w-full text-left border rounded-lg p-4 flex items-center gap-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<UserAvatar name={entry.user.full_name} email={entry.user.email} avatarUrl={entry.user.avatar_url} />
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{entry.user.full_name ?? <span className="italic text-muted-foreground">No name</span>}
|
||||
</span>
|
||||
<StatusBadge status={entry.course_status} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">{entry.user.email}</p>
|
||||
<ProgressBar value={entry.lessons_completed} total={entry.lessons_total} />
|
||||
<p className="text-xs">Last seen {lastSeen}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pagination controls ──────────────────────────────────────────────────────
|
||||
|
||||
function PaginationControls({ page, totalPages, onPage }) {
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const pages = [];
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
|
||||
// Show at most 5 page numbers around current
|
||||
const getVisible = () => {
|
||||
if (totalPages <= 5) return pages;
|
||||
if (page <= 3) return [1, 2, 3, 4, null, totalPages];
|
||||
if (page >= totalPages - 2) return [1, null, totalPages - 3, totalPages - 2, totalPages - 1, totalPages];
|
||||
return [1, null, page - 1, page, page + 1, null, totalPages];
|
||||
};
|
||||
|
||||
return (
|
||||
<Pagination className="mt-4">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); if (page > 1) onPage(page - 1); }}
|
||||
className={page === 1 ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getVisible().map((p, i) =>
|
||||
p === null ? (
|
||||
<PaginationItem key={`ellipsis-${i}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive={p === page}
|
||||
onClick={(e) => { e.preventDefault(); onPage(p); }}
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); if (page < totalPages) onPage(page + 1); }}
|
||||
className={page === totalPages ? 'pointer-events-none opacity-50' : ''}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function CourseReadingProgressList({ courseId }) {
|
||||
const { progressList, listLoading, fetchCourseReadingProgress } = useAdminCourseReadingProgress();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [dialogEntry, setDialogEntry] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCourseReadingProgress(courseId);
|
||||
}, [courseId]);
|
||||
|
||||
// Reset to page 1 when search changes
|
||||
useEffect(() => { setPage(1); }, [search]);
|
||||
|
||||
// ── Filter ────────────────────────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return progressList;
|
||||
return progressList.filter((e) =>
|
||||
e.user.full_name?.toLowerCase().includes(q) ||
|
||||
e.user.email?.toLowerCase().includes(q)
|
||||
);
|
||||
}, [progressList, search]);
|
||||
|
||||
// ── Paginate ──────────────────────────────────────────────────────────────
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const completedCount = progressList.filter((e) => e.course_status === 'completed').length;
|
||||
const inProgressCount = progressList.length - completedCount;
|
||||
|
||||
// ── Loading skeleton ──────────────────────────────────────────────────────
|
||||
if (listLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="border rounded-lg p-4 flex items-center gap-3">
|
||||
<Skeleton className="size-9 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3.5 w-40" />
|
||||
<Skeleton className="h-3 w-56" />
|
||||
<Skeleton className="h-1.5 w-full rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-20 rounded-full shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────
|
||||
if (!progressList.length) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center gap-2">
|
||||
<BookOpen className="size-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">No students have started reading this course yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Summary strip ── */}
|
||||
<div className="flex items-center gap-4 flex-wrap text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{progressList.length} enrolled
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<CheckCircle2 className="size-3.5 text-emerald-500" />
|
||||
{completedCount} completed
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Circle className="size-3.5 text-amber-400" />
|
||||
{inProgressCount} in progress
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Search ── */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Search by name or email…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value.slice(0, 50))}
|
||||
maxLength={50}
|
||||
className="pl-9 pr-16"
|
||||
/>
|
||||
<span className={`absolute right-3 top-1/2 -translate-y-1/2 text-xs tabular-nums pointer-events-none ${search.length >= 50 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{search.length}/50
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── List ── */}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-6">No results for "{search}".</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{paginated.map((entry) => (
|
||||
<UserCard
|
||||
key={entry.user_id}
|
||||
entry={entry}
|
||||
onOpen={setDialogEntry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Pagination ── */}
|
||||
<PaginationControls page={page} totalPages={totalPages} onPage={setPage} />
|
||||
|
||||
{/* ── Detail Dialog ── */}
|
||||
<UserDetailDialog
|
||||
open={!!dialogEntry}
|
||||
onOpenChange={(v) => { if (!v) setDialogEntry(null); }}
|
||||
entry={dialogEntry}
|
||||
courseId={courseId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export default function CoursesTable() {
|
||||
};
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onViewAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment/view`),
|
||||
onAssessment: (row) => navigate(`/admin/courses/${row.course_id}/assessment`),
|
||||
onViewUnits: (row) => navigate(`/admin/courses/${row.course_id}/units`),
|
||||
onView: (row) => navigate(`/admin/courses/${row.course_id}/view`),
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
import { Eye, ImageIcon, VideoIcon } from "lucide-react";
|
||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/TextBlock";
|
||||
/* A similar strategy to BlockList.jsx so this will applies
|
||||
to Client side */
|
||||
|
||||
import { Eye, ImageIcon, VideoIcon, ZoomIn } from "lucide-react";
|
||||
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
|
||||
import { PhotoProvider, PhotoView } from "react-photo-view";
|
||||
import { VideoBlock } from "@/components/generic/Blocks/Client/VideoBlock";
|
||||
import { TextVideoBlock } from "@/components/generic/Blocks/Client/TextVideoBlock";
|
||||
import { TextImageBlock } from "@/components/generic/Blocks/Client/TextImageBlock";
|
||||
import { ImageBlock } from "@/components/generic/Blocks/Client/ImageBlock";
|
||||
import { TextBlock } from "@/components/generic/Blocks/Client/TextBlock";
|
||||
import { AudioBlock } from "@/components/generic/Blocks/Client/AudioBlock";
|
||||
import { CodeBlock } from "@/components/generic/Blocks/Client/CodeBlock";
|
||||
import { MarkdownBlock } from "@/components/generic/Blocks/Client/MarkdownBlock";
|
||||
|
||||
export function LessonHeader({ lesson }) {
|
||||
if (!lesson) return null;
|
||||
return (
|
||||
<div className="space-y-4 pb-2">
|
||||
<div className="space-y-3 pb-2 sm:space-y-4">
|
||||
<div>
|
||||
<h1 style={{ fontSize: "1.875rem", fontWeight: 700, lineHeight: 1.2, margin: "0 0 0.4rem 0" }}>
|
||||
<h1 className="text-2xl font-bold leading-tight mb-1 sm:text-3xl sm:leading-[1.2]">
|
||||
{lesson.title}
|
||||
</h1>
|
||||
{lesson.description && (
|
||||
<p style={{ margin: "0.2rem 0", lineHeight: 1.75, textAlign: "justify" }}
|
||||
className="text-muted-foreground">
|
||||
<p className="mt-1 leading-relaxed text-sm text-muted-foreground sm:text-base sm:leading-[1.75] sm:text-justify">
|
||||
{lesson.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lesson.objectives?.length > 0 && (
|
||||
<div className="rounded-lg border p-4 space-y-3">
|
||||
<div className="rounded-lg border p-3 space-y-2 sm:p-4 sm:space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-md bg-green-100 flex items-center justify-center shrink-0">
|
||||
<div className="h-7 w-7 rounded-md bg-green-100 flex items-center justify-center shrink-0 sm:h-8 sm:w-8">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4 text-green-600"
|
||||
className="h-3.5 w-3.5 text-green-600 sm:h-4 sm:w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -36,11 +47,11 @@ export function LessonHeader({ lesson }) {
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-semibold">Objective</p>
|
||||
<p className="text-xs font-semibold sm:text-sm">Objective</p>
|
||||
</div>
|
||||
<ul className="space-y-1.5 list-disc list-inside">
|
||||
<ul className="space-y-1 list-disc list-inside sm:space-y-1.5">
|
||||
{lesson.objectives.map((o) => (
|
||||
<li key={o.objective_id} className="text-sm text-muted-foreground">
|
||||
<li key={o.objective_id} className="text-xs text-muted-foreground sm:text-sm">
|
||||
{o.text}
|
||||
</li>
|
||||
))}
|
||||
@@ -51,6 +62,32 @@ export function LessonHeader({ lesson }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Zoomable image wrapper ───────────────────────────────────────────────────
|
||||
// Must be rendered inside a <PhotoProvider>. Shows a subtle zoom hint on hover.
|
||||
|
||||
export function ZoomableImage({ url, alt }) {
|
||||
if (!url) return null;
|
||||
return (
|
||||
<PhotoView src={url}>
|
||||
<div className="relative group cursor-zoom-in">
|
||||
<img
|
||||
src={url}
|
||||
alt={alt ?? ""}
|
||||
className="w-full rounded-md object-cover aspect-video"
|
||||
draggable={false}
|
||||
/>
|
||||
{/* Zoom hint badge — fades in on hover */}
|
||||
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
|
||||
<div className="flex items-center gap-1 bg-secondary text-sm px-2 py-1 rounded-full border">
|
||||
<ZoomIn className="size-4" />
|
||||
<span>Zoom</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PhotoView>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewImage({ url, alt }) {
|
||||
if (!url) {
|
||||
return (
|
||||
@@ -60,9 +97,7 @@ export function PreviewImage({ url, alt }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img src={url} alt={alt ?? ""} className="w-full rounded-md object-cover aspect-video" />
|
||||
);
|
||||
return <ZoomableImage url={url} alt={alt} />;
|
||||
}
|
||||
|
||||
export function PreviewVideo({ url, thumb }) {
|
||||
@@ -80,129 +115,107 @@ export function PreviewVideo({ url, thumb }) {
|
||||
<img src={thumb} alt="Video thumbnail" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<VideoIcon className="h-10 w-10 text-muted-foreground/40" />
|
||||
<VideoIcon className="h-8 w-8 text-muted-foreground/40 sm:h-10 sm:w-10" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="h-10 w-10 rounded-full bg-black/50 flex items-center justify-center">
|
||||
<VideoIcon className="h-5 w-5 text-white" />
|
||||
<div className="h-8 w-8 rounded-full bg-black/50 flex items-center justify-center sm:h-10 sm:w-10">
|
||||
<VideoIcon className="h-4 w-4 text-white sm:h-5 sm:w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 inset-x-0 bg-black/60 px-2 py-1">
|
||||
<p className="text-white text-[10px] truncate">{url}</p>
|
||||
<p className="text-white text-[9px] truncate sm:text-[10px]">{url}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewBlock({ block }) {
|
||||
const { type, content } = block;
|
||||
const { id, type, content } = block;
|
||||
|
||||
if (type === "text") {
|
||||
if (!content.body) {
|
||||
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body }}
|
||||
/>
|
||||
);
|
||||
switch (type) {
|
||||
case "text":
|
||||
return <TextBlock blockId={id} content={content} readOnly />;
|
||||
case "image":
|
||||
return <ImageBlock content={content} readOnly />;
|
||||
case "text-image":
|
||||
return <TextImageBlock blockId={id} content={content} readOnly />;
|
||||
case "video":
|
||||
return <VideoBlock content={content} readOnly />;
|
||||
case "text-video":
|
||||
return <TextVideoBlock blockId={id} content={content} readOnly />;
|
||||
case "audio":
|
||||
return <AudioBlock content={content} />;
|
||||
case "code":
|
||||
return <CodeBlock content={content} />;
|
||||
case "markdown":
|
||||
return <MarkdownBlock content={content} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === "image") {
|
||||
if (!content.url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-24 rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
No image selected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<figure>
|
||||
<img src={content.url} alt={content.alt ?? ""} className="w-full rounded-md object-cover" />
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "text-image") {
|
||||
const imgLeft = content.image_position === "left";
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 items-start">
|
||||
{imgLeft && <PreviewImage url={content.url} alt={content.alt} />}
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
|
||||
/>
|
||||
{!imgLeft && <PreviewImage url={content.url} alt={content.alt} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "video") {
|
||||
if (!content.url) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-24 rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
|
||||
<VideoIcon className="h-4 w-4" />
|
||||
No video selected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <PreviewVideo url={content.url} thumb={content.thumbnail_url} />;
|
||||
}
|
||||
|
||||
if (type === "text-video") {
|
||||
const vidLeft = content.video_position === "left";
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 items-start">
|
||||
{vidLeft && <PreviewVideo url={content.url} thumb={content.thumbnail_url} />}
|
||||
<div
|
||||
className="wysiwyg-preview text-sm"
|
||||
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
|
||||
/>
|
||||
{!vidLeft && <PreviewVideo url={content.url} thumb={content.thumbnail_url} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── PreviewContent ───────────────────────────────────────────────────────────
|
||||
// PhotoProvider wraps ALL blocks so images across the whole lesson share
|
||||
// one lightbox session — users can swipe between them naturally.
|
||||
|
||||
export function PreviewContent({ lesson, blocks, empty = "No content yet." }) {
|
||||
return (
|
||||
<>
|
||||
<PhotoProvider
|
||||
speed={() => 300}
|
||||
easing={(type) => (type === 2 ? "cubic-bezier(0.36, 0, 0.66, -0.56)" : "cubic-bezier(0.34, 1.56, 0.64, 1)")}
|
||||
toolbarRender={({ onScale, scale, rotate, onRotate }) => (
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<button
|
||||
onClick={() => onScale(scale + 0.5)}
|
||||
className="text-white/80 hover:text-white transition-colors"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<style>{WYSIWYG_STYLES}</style>
|
||||
<LessonHeader lesson={lesson} />
|
||||
{blocks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-sm text-muted-foreground">
|
||||
<Eye className="h-8 w-8 opacity-20" />
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-sm text-muted-foreground sm:py-16">
|
||||
<Eye className="h-6 w-6 opacity-20 sm:h-8 sm:w-8" />
|
||||
<p>{empty}</p>
|
||||
</div>
|
||||
) : (
|
||||
blocks.map((block) => (
|
||||
<div key={block.id}>
|
||||
<PreviewBlock block={block} />
|
||||
</div>
|
||||
))
|
||||
<div className="space-y-4 sm:space-y-5">
|
||||
{blocks.map((block) => (
|
||||
<div key={block.id}>
|
||||
<PreviewBlock block={block} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</PhotoProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreviewChrome({ title, children }) {
|
||||
export function PreviewChrome({ title, children, showChrome = true }) {
|
||||
if (!showChrome) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<style>{WYSIWYG_STYLES}</style>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 bg-muted/60 border-b">
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-red-400" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-yellow-400" />
|
||||
<span className="h-2.5 w-2.5 rounded-full bg-green-400" />
|
||||
<div className="flex-1 mx-3 h-5 rounded bg-background/60 border text-[10px] flex items-center px-2 text-muted-foreground/60 truncate">
|
||||
<span className="hidden h-2.5 w-2.5 rounded-full bg-red-400 sm:inline-block" />
|
||||
<span className="hidden h-2.5 w-2.5 rounded-full bg-yellow-400 sm:inline-block" />
|
||||
<span className="hidden h-2.5 w-2.5 rounded-full bg-green-400 sm:inline-block" />
|
||||
<div className="flex-1 sm:mx-3 h-5 rounded bg-background/60 border text-[10px]
|
||||
flex items-center px-2 text-muted-foreground/60 truncate">
|
||||
{title ?? "Lesson Preview"}
|
||||
</div>
|
||||
<Eye className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
<Eye className="h-3.5 w-3.5 text-muted-foreground/60 shrink-0" />
|
||||
</div>
|
||||
<div className="p-3 sm:p-5">
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export default function UnitsTable({ courseId }) {
|
||||
);
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onViewQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz/view`),
|
||||
onQuiz: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/quiz`),
|
||||
onViewLessons: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/lessons`),
|
||||
onView: (row) => navigate(`/admin/courses/${courseId}/units/${row.unit_id}/view`),
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo, useRef, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import { buildDataColumns, columnPinning } from "../../config/tiers/payments/columns.config";
|
||||
import { buildToolbarActions } from "../../config/tiers/payments/toolbar.config";
|
||||
import { buildSelectionActions } from "../../config/tiers/payments/selection.config";
|
||||
import { buildRowActions } from "../../config/tiers/payments/rowActions.config";
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function PaymentsTable({ planId = null }) {
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => { },
|
||||
setFilters: () => { },
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
payments, paymentAttributes,
|
||||
paymentPagination, setPaymentPagination,
|
||||
loading, fetchPayments,
|
||||
} = useTiers();
|
||||
|
||||
const exportConfig = {
|
||||
allData: payments,
|
||||
attributes: paymentAttributes,
|
||||
filename: `${getTimestamp()}_Payments`,
|
||||
sheetName: "Payments",
|
||||
};
|
||||
|
||||
const rowActions = useMemo(() => buildRowActions({
|
||||
onView: (row) => navigate(`/admin/tiers/payments/${row.payment_id}/view`),
|
||||
}), []);
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchPayments,
|
||||
pagination: paymentPagination,
|
||||
exportConfig,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(paymentAttributes, rowActions),
|
||||
[paymentAttributes]
|
||||
);
|
||||
|
||||
// Pass planId as a locked filter to DataTable's onFetch
|
||||
const handleFetch = useCallback((params = {}) => {
|
||||
const baseFilters = planId
|
||||
? [{ field: "plan_id", value: planId }, ...(params.filters ?? [])]
|
||||
: (params.filters ?? []);
|
||||
fetchPayments({ ...params, filters: baseFilters });
|
||||
}, [planId, fetchPayments]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
title="Payments"
|
||||
data={payments}
|
||||
columns={columns}
|
||||
attributes={paymentAttributes}
|
||||
pagination={paymentPagination}
|
||||
setPagination={setPaymentPagination}
|
||||
loading={loading}
|
||||
onFetch={handleFetch}
|
||||
onFetchFilterData={async () => []}
|
||||
onRefsReady={(refs) => { tableRefsRef.current = refs; }}
|
||||
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="payment"
|
||||
emptyMessage="No payments found."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useMemo, useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useTiers } from "@/contexts/AdminTiersContext";
|
||||
|
||||
import DataTable from "@/components/generic/Table/DataTable";
|
||||
import { FilterSheet } from "@/components/generic/Sheet/FilterSheet";
|
||||
import { ArchiveDialog } from "@/components/generic/Dialogs/ArchiveDialog";
|
||||
import { RestoreDialog } from "@/components/generic/Dialogs/RestoreDialog";
|
||||
|
||||
import { buildDataColumns, columnPinning } from "../../config/tiers/plans/columns.config";
|
||||
import { buildToolbarActions } from "../../config/tiers/plans/toolbar.config";
|
||||
import { buildSelectionActions } from "../../config/tiers/plans/selection.config";
|
||||
import { buildRowActions } from "../../config/tiers/plans/rowActions.config";
|
||||
|
||||
import { getTimestamp } from "@/utils/timestamp.util";
|
||||
|
||||
export default function TierPlansTable() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
plans, planAttributes, planPagination, setPlanPagination,
|
||||
loading, fetchPlans, deletePlan, restorePlan,
|
||||
bulkDeletePlans, bulkRestorePlans,
|
||||
} = useTiers();
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archiveTarget, setArchiveTarget] = useState(null);
|
||||
const [restoreTarget, setRestoreTarget] = useState(null);
|
||||
const [archiveIds, setArchiveIds] = useState(null);
|
||||
const [restoreIds, setRestoreIds] = useState(null);
|
||||
|
||||
const tableRefsRef = useRef({
|
||||
getFilters: () => [],
|
||||
getSort: () => [],
|
||||
resetSelection: () => {},
|
||||
tableInstance: null,
|
||||
});
|
||||
|
||||
const handleRefsReady = (refs) => { tableRefsRef.current = refs; };
|
||||
|
||||
const handleToggleArchived = useCallback(() => {
|
||||
const next = !showArchived;
|
||||
setShowArchived(next);
|
||||
fetchPlans({
|
||||
page: 1,
|
||||
limit: planPagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
archived: next,
|
||||
});
|
||||
}, [showArchived, planPagination, fetchPlans]);
|
||||
|
||||
const handleSuccess = () => {
|
||||
setArchiveTarget(null);
|
||||
setRestoreTarget(null);
|
||||
setArchiveIds(null);
|
||||
setRestoreIds(null);
|
||||
tableRefsRef.current.resetSelection?.();
|
||||
fetchPlans({
|
||||
page: 1,
|
||||
limit: planPagination?.limit ?? 10,
|
||||
filters: tableRefsRef.current.getFilters(),
|
||||
sort: tableRefsRef.current.getSort(),
|
||||
archived: showArchived,
|
||||
});
|
||||
};
|
||||
|
||||
const exportConfig = useMemo(() => ({
|
||||
allData: plans,
|
||||
attributes: planAttributes,
|
||||
filename: `${getTimestamp()}_TierPlans`,
|
||||
sheetName: "Tier Plans",
|
||||
}), [plans, planAttributes]);
|
||||
|
||||
const rowActions = buildRowActions({
|
||||
navigate,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onRestore: (row) => setRestoreTarget(row),
|
||||
showArchived,
|
||||
});
|
||||
|
||||
const toolbarActions = buildToolbarActions({
|
||||
fetchPlans,
|
||||
pagination: planPagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
showArchived,
|
||||
onToggleArchived: handleToggleArchived,
|
||||
getFilters: () => tableRefsRef.current.getFilters(),
|
||||
getSort: () => tableRefsRef.current.getSort(),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const selectionActions = buildSelectionActions({
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onArchive: (row) => setArchiveTarget(row),
|
||||
onArchiveMany: (ids) => setArchiveIds(ids),
|
||||
onRestoreMany: (ids) => setRestoreIds(ids),
|
||||
getTableInstance: () => tableRefsRef.current.tableInstance,
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildDataColumns(planAttributes, rowActions),
|
||||
[planAttributes, rowActions]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
title="Tier Plans"
|
||||
data={plans}
|
||||
columns={columns}
|
||||
attributes={planAttributes}
|
||||
pagination={planPagination}
|
||||
setPagination={setPlanPagination}
|
||||
loading={loading}
|
||||
onFetch={fetchPlans}
|
||||
onFetchFilterData={async () => []}
|
||||
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="plan"
|
||||
emptyMessage="No tier plans found."
|
||||
/>
|
||||
|
||||
{/* Single archive */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveTarget}
|
||||
onOpenChange={(v) => !v && setArchiveTarget(null)}
|
||||
entity={archiveTarget}
|
||||
entityLabel="Plan"
|
||||
getName={(r) => r?.label}
|
||||
onArchive={(entity) => deletePlan(entity?.plan_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* Single restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreTarget}
|
||||
onOpenChange={(v) => !v && setRestoreTarget(null)}
|
||||
entity={restoreTarget}
|
||||
entityLabel="Plan"
|
||||
getName={(r) => r?.label}
|
||||
onRestore={(entity) => restorePlan(entity?.plan_id)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* Bulk archive */}
|
||||
<ArchiveDialog
|
||||
open={!!archiveIds}
|
||||
onOpenChange={(v) => !v && setArchiveIds(null)}
|
||||
ids={archiveIds ?? []}
|
||||
entityLabel="Plan"
|
||||
onArchive={({ ids }) => bulkDeletePlans(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
|
||||
{/* Bulk restore */}
|
||||
<RestoreDialog
|
||||
open={!!restoreIds}
|
||||
onOpenChange={(v) => !v && setRestoreIds(null)}
|
||||
ids={restoreIds ?? []}
|
||||
entityLabel="Plan"
|
||||
onRestore={({ ids }) => bulkRestorePlans(ids)}
|
||||
loading={loading}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -129,9 +130,9 @@ export function AddGroupDialog({ open, onOpenChange, onSubmit, loading }) {
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline" disabled={loading}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Creating..." : "Add group"}
|
||||
</Button>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -124,9 +125,9 @@ export function EditGroupDialog({ open, onOpenChange, group, onSubmit, loading }
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline" disabled={loading}>Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Eye, Pencil, Archive, ShelvingUnit, NotebookPen } from "lucide-react";
|
||||
import { Eye, Pencil, Archive, ShelvingUnit, NotebookPen, ClipboardList } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment }) {
|
||||
export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAssessment, onViewAssessment }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
@@ -22,6 +22,14 @@ export function buildRowActions({ onViewUnits, onView, onEdit, onArchive, onAsse
|
||||
onClick: (row) => onViewUnits(row),
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
key: "view_assessment",
|
||||
label: "View Assessment",
|
||||
icon: <ClipboardList className="h-3.5 w-3.5" />,
|
||||
className: "text-purple-700 hover:text-purple-600",
|
||||
onClick: (row) => onViewAssessment(row),
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "modify_assessment",
|
||||
label: "Modify Assessment",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { Plus, RefreshCw, Download, Archive, ArrowUpAZIcon } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
@@ -43,6 +43,14 @@ export function buildToolbarActions({
|
||||
variant: "default",
|
||||
onClick: () => navigate("/admin/courses/add"),
|
||||
},
|
||||
{
|
||||
key: "categories",
|
||||
type: "button",
|
||||
label: "Categories",
|
||||
icon: <ArrowUpAZIcon className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
onClick: () => navigate("/admin/courses/categories"),
|
||||
},
|
||||
{
|
||||
key: "archived-courses",
|
||||
type: "button",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Eye, Pencil, Archive, BookCheck, NotebookPen } from "lucide-react";
|
||||
import { Eye, Pencil, Archive, BookCheck, NotebookPen, ClipboardList } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQuiz }) {
|
||||
export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQuiz, onViewQuiz }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
@@ -22,13 +22,21 @@ export function buildRowActions({ onViewLessons, onView, onEdit, onArchive, onQu
|
||||
onClick: (row) => onViewLessons(row),
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
key: "view_quiz",
|
||||
label: "View Quiz",
|
||||
icon: <ClipboardList className="h-3.5 w-3.5" />,
|
||||
className: "text-purple-700 hover:text-purple-600",
|
||||
onClick: (row) => onViewQuiz(row),
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "modify_quiz",
|
||||
label: "Modify Quiz",
|
||||
icon: <NotebookPen className="h-3.5 w-3.5" />,
|
||||
className: "text-purple-700 hover:text-purple-600",
|
||||
onClick: (row) => onQuiz(row),
|
||||
separator: true
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Eye, Pencil, Archive, ArchiveRestore, Info } from "lucide-react";
|
||||
import { Eye, Pencil, Archive, ArchiveRestore, Info, NotebookPen } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
||||
return [
|
||||
@@ -14,6 +14,14 @@ export function buildRowActions({ navigate, onArchive, onRestore, showArchived }
|
||||
icon: <Pencil className="size-4" />,
|
||||
onClick: (row) => navigate(`${row.task_id}/edit`),
|
||||
},
|
||||
{
|
||||
key: "completions",
|
||||
label: "Completions",
|
||||
icon: <NotebookPen className="size-4" />,
|
||||
onClick: (row) => navigate(`${row.task_id}/completions`),
|
||||
separator: true,
|
||||
className: "text-sky-600",
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// config/task_completion/columns.config.jsx
|
||||
// Column definitions and pinning config for the TaskCompletions table.
|
||||
import { format } from "date-fns";
|
||||
import { buildColumns, longTextCell } from "@/utils/table.util";
|
||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
// ─── Shared 12-hour timestamp formatter ───────────────────────────────────────
|
||||
const formatTimestamp = (value) => {
|
||||
if (!value) return <span className="text-muted-foreground/40">-</span>;
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(value), "MMM d, yyyy · h:mm a")}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||
const cellOverrides = {
|
||||
completion_id: longTextCell("Completion ID"),
|
||||
task_id: longTextCell("Task ID"),
|
||||
note: longTextCell("Note"),
|
||||
submitted_at: (info) => formatTimestamp(info.getValue()),
|
||||
createdAt: (info) => formatTimestamp(info.getValue()),
|
||||
updatedAt: (info) => formatTimestamp(info.getValue()),
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the TaskCompletions 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: "Completion Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// config/task_completion/rowActions.config.jsx
|
||||
import { Eye, Archive, RotateCcw } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View",
|
||||
icon: <Eye className="size-4" />,
|
||||
onClick: (row) => navigate(`${row.completion_id}/view`),
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
className: "text-destructive focus:text-destructive",
|
||||
icon: <Archive className="size-4" />,
|
||||
onClick: (row) => onArchive(row),
|
||||
hidden: () => showArchived,
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
label: "Restore",
|
||||
className: "text-green-600 focus:text-green-600",
|
||||
icon: <RotateCcw className="size-4" />,
|
||||
onClick: (row) => onRestore(row),
|
||||
hidden: () => !showArchived,
|
||||
separator: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// config/task_completion/selection.config.jsx
|
||||
import { Download, Archive, RotateCcw } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildSelectionActions({
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onBulkArchive,
|
||||
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: showArchived ? "restore-selected" : "archive-selected",
|
||||
label: showArchived ? "Restore" : "Archive",
|
||||
icon: showArchived ? (
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<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.completion_id).filter(Boolean);
|
||||
if (!ids.length) return;
|
||||
showArchived ? onBulkRestore?.(ids) : onBulkArchive?.(ids);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// config/task_completion/toolbar.config.jsx
|
||||
import { RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchCompletions,
|
||||
taskListId,
|
||||
taskId,
|
||||
pagination,
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onToggleArchived,
|
||||
getFilters,
|
||||
getSort,
|
||||
getTableInstance,
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
key: "refresh",
|
||||
type: "button",
|
||||
icon: <RefreshCw className="size-4" />,
|
||||
label: "Refresh",
|
||||
onClick: () => {
|
||||
fetchCompletions(taskListId, taskId, {
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters?.() ?? [],
|
||||
sort: getSort?.() ?? [],
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
icon: <Download className="size-4" />,
|
||||
label: "Export",
|
||||
onClick: (table) =>
|
||||
exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: table ?? getTableInstance?.(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "toggle-archived",
|
||||
type: "button",
|
||||
icon: <Archive className="size-4" />,
|
||||
label: showArchived ? "Active" : "Archived",
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
onClick: onToggleArchived,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buildColumns } from "@/utils/table.util";
|
||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
const STATUS_BADGE = {
|
||||
pending: "secondary",
|
||||
completed: "default",
|
||||
failed: "destructive",
|
||||
cancelled: "outline",
|
||||
expired: "outline",
|
||||
refunded: "outline",
|
||||
};
|
||||
|
||||
const TIER_BADGE = { premium: "default", exclusive: "destructive" };
|
||||
|
||||
const cellOverrides = {
|
||||
status: (info) => (
|
||||
<Badge variant={STATUS_BADGE[info.getValue()] ?? "outline"} className="capitalize">
|
||||
{info.getValue()}
|
||||
</Badge>
|
||||
),
|
||||
amount: (info) => {
|
||||
const row = info.row.original;
|
||||
return (
|
||||
<span className="text-sm font-medium">
|
||||
{row.currency} {Number(info.getValue()).toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
"plan.tier": (info) => (
|
||||
<Badge variant={TIER_BADGE[info.getValue()] ?? "outline"} className="capitalize">
|
||||
{info.getValue()}
|
||||
</Badge>
|
||||
),
|
||||
paid_at: (info) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{info.getValue() ? new Date(info.getValue()).toLocaleString() : "—"}
|
||||
</span>
|
||||
),
|
||||
"user.email": (info) => (
|
||||
<span className="text-sm">{info.getValue() ?? "—"}</span>
|
||||
),
|
||||
|
||||
};
|
||||
|
||||
export function buildDataColumns(attributes, rowActions) {
|
||||
const visibleAttributes = attributes.filter((a) => !a.hidden);
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "Payment Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Eye } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ onView }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View Payment",
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => onView(row),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildSelectionActions({ exportConfig, 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(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { RefreshCw, Download } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchPayments,
|
||||
pagination,
|
||||
exportConfig,
|
||||
getFilters,
|
||||
getSort,
|
||||
getTableInstance,
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
key: "refresh",
|
||||
type: "button",
|
||||
label: "Refresh",
|
||||
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => fetchPayments({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters(),
|
||||
sort: getSort(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: getTableInstance(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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 { Book, BookOpenCheck, Clock } from "lucide-react";
|
||||
import { formatDuration } from "@/utils/timestamp.util";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
|
||||
// ─── Custom cell overrides ────────────────────────────────────────────────────
|
||||
const cellOverrides = {
|
||||
unitCount: (info) => {
|
||||
const count = parseInt(info.getValue() ?? 0, 10);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Book className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{count} {count === 1 ? "unit" : "units"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
lessonCount: (info) => {
|
||||
const count = parseInt(info.getValue() ?? 0, 10);
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookOpenCheck className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{count} {count === 1 ? "lesson" : "lessons"}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
duration_seconds: (info) => {
|
||||
const seconds = parseInt(info.getValue() ?? 0, 10);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" className="text-xs font-medium tabular-nums">
|
||||
{formatDuration(seconds)}
|
||||
</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: "Course Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Eye, Pencil, Archive, RotateCcw, ShelvingUnit } from "lucide-react";
|
||||
|
||||
export function buildRowActions({ navigate, onArchive, onRestore, showArchived }) {
|
||||
return [
|
||||
{
|
||||
key: "view",
|
||||
label: "View Plan",
|
||||
icon: <Eye className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/view`),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit Plan",
|
||||
icon: <Pencil className="h-3.5 w-3.5" />,
|
||||
onClick: (row) => navigate(`/admin/tiers/plans/${row.plan_id}/edit`),
|
||||
hidden: () => showArchived,
|
||||
},
|
||||
{
|
||||
key: "view_units",
|
||||
label: "View Payments",
|
||||
icon: <ShelvingUnit className="h-3.5 w-3.5" />,
|
||||
className: "text-sky-700 hover:text-sky-600",
|
||||
onClick: (row) => navigate(`/admin/tiers/payments?plan_id=${row.plan_id}`),
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
key: "archive",
|
||||
label: "Archive",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
className: "text-destructive",
|
||||
onClick: (row) => onArchive(row),
|
||||
separator: true,
|
||||
hidden: () => showArchived,
|
||||
},
|
||||
{
|
||||
key: "restore",
|
||||
label: "Restore",
|
||||
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||
className: "text-emerald-600",
|
||||
onClick: (row) => onRestore(row),
|
||||
hidden: () => !showArchived,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Download, Archive, RotateCcw } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildSelectionActions({
|
||||
exportConfig,
|
||||
showArchived,
|
||||
onArchive,
|
||||
onArchiveMany,
|
||||
onRestoreMany,
|
||||
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(),
|
||||
}),
|
||||
},
|
||||
!showArchived && {
|
||||
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.plan_id);
|
||||
ids.length === 1 ? onArchive(rows[0]) : onArchiveMany(ids);
|
||||
},
|
||||
},
|
||||
showArchived && {
|
||||
key: "restore-selected",
|
||||
label: "Restore",
|
||||
icon: <RotateCcw className="h-3.5 w-3.5" />,
|
||||
className: "text-emerald-600 border-emerald-600/40 hover:bg-emerald-600/10 hover:text-emerald-600",
|
||||
onClick: (rows) => {
|
||||
const ids = rows.map((r) => r.plan_id);
|
||||
onRestoreMany(ids);
|
||||
},
|
||||
},
|
||||
].filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Plus, RefreshCw, Download, Archive } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
export function buildToolbarActions({
|
||||
fetchPlans,
|
||||
pagination,
|
||||
exportConfig,
|
||||
navigate,
|
||||
showArchived,
|
||||
onToggleArchived,
|
||||
getFilters,
|
||||
getSort,
|
||||
getTableInstance,
|
||||
}) {
|
||||
return [
|
||||
{
|
||||
key: "refresh",
|
||||
type: "button",
|
||||
label: "Refresh",
|
||||
icon: <RefreshCw className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => fetchPlans({
|
||||
page: 1,
|
||||
limit: pagination?.limit ?? 10,
|
||||
filters: getFilters(),
|
||||
sort: getSort(),
|
||||
archived: showArchived,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
type: "button",
|
||||
label: "Export",
|
||||
icon: <Download className="h-3.5 w-3.5" />,
|
||||
variant: "outline",
|
||||
onClick: () => exportTableToExcel({
|
||||
...exportConfig,
|
||||
tableInstance: getTableInstance(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: "create",
|
||||
type: "button",
|
||||
label: "New Plan",
|
||||
icon: <Plus className="h-3.5 w-3.5" />,
|
||||
variant: "default",
|
||||
hidden: showArchived,
|
||||
onClick: () => navigate("/admin/tiers/plans/add"),
|
||||
},
|
||||
{
|
||||
key: "toggle-archived",
|
||||
type: "button",
|
||||
icon: <Archive className="h-3.5 w-3.5" />,
|
||||
label: showArchived ? "Active Plans" : "Archived Plans",
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
onClick: onToggleArchived,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -4,12 +4,26 @@
|
||||
import { buildColumns } from "@/utils/table.util";
|
||||
import { buildSelectionColumn } from "@/components/generic/Table/buildSelectionColumn";
|
||||
import { buildRowActionsColumn } from "@/components/generic/Table/buildRowActionsColumn";
|
||||
import { OverflowBadges } from "@/components/generic/OverflowBadges";
|
||||
|
||||
export const columnPinning = {
|
||||
right: ["actions"],
|
||||
left: [],
|
||||
};
|
||||
|
||||
const cellOverrides = {
|
||||
groups: (info) => (
|
||||
<OverflowBadges
|
||||
items={info.getValue() ?? []}
|
||||
keyKey="group_id"
|
||||
labelKey="group_code"
|
||||
dialogTitleKey="name"
|
||||
dialogTitle="All groups"
|
||||
badgeClassName="text-xs font-mono"
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the full column array for the Users table.
|
||||
*
|
||||
@@ -22,7 +36,7 @@ export function buildUserColumns(attributes, rowActions) {
|
||||
|
||||
return [
|
||||
buildSelectionColumn(),
|
||||
...buildColumns(visibleAttributes),
|
||||
...buildColumns(visibleAttributes, { cellOverrides }),
|
||||
buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }),
|
||||
];
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// ─── config/toolbar.config.jsx ────────────────────────────────────────────────
|
||||
import { RefreshCw, Download, UserPlus, Archive } from "lucide-react";
|
||||
import { RefreshCw, Download, UserPlus, Archive, Activity } from "lucide-react";
|
||||
import { exportTableToExcel } from "@/utils/excel.util";
|
||||
|
||||
/**
|
||||
@@ -34,6 +34,15 @@ export function buildToolbarActions({ fetchUsers, pagination, exportConfig, navi
|
||||
className: "text-primary-foreground",
|
||||
onClick: () => navigate("add/staff"),
|
||||
},
|
||||
{
|
||||
key: "activities",
|
||||
type: "button",
|
||||
icon: <Activity className="h-3.5 w-3.5" />,
|
||||
label: "Activities",
|
||||
variant: "secondary",
|
||||
className: "border border-border",
|
||||
onClick: () => navigate("/admin/activity"),
|
||||
},
|
||||
{
|
||||
key: "archived-users",
|
||||
type: "button",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* Oct. 10, 2025 lash0000 001 Initial creation - STAR Phase 1 Project
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { Outlet, useNavigate } from "react-router-dom"
|
||||
import { useRef, useEffect } from "react"
|
||||
import { useAuth } from "@/contexts/AuthContext"
|
||||
import { AdminProvider } from "@/contexts/provider/AdminProvider" // ← add
|
||||
|
||||
@@ -20,74 +21,75 @@ import { Toaster } from "sonner"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import UserMenu from "@/components/generic/UserMenu"
|
||||
import NotificationBell from "@/components/generic/NotificationBell"
|
||||
import { ROLE_CONFIG } from "@/data/profile.data"
|
||||
|
||||
const AdminLayout = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const headerRef = useRef(null)
|
||||
|
||||
const role = ROLE_CONFIG[user?.acc_type] ?? { label: user?.acc_type, variant: 'outline' }
|
||||
|
||||
async function handleLogout() {
|
||||
// setSignOutOpen(true);
|
||||
// setSignOutLoading(true);
|
||||
|
||||
// try {
|
||||
// await logout();
|
||||
// navigate("/", { replace: true });
|
||||
// } finally {
|
||||
// setSignOutLoading(false);
|
||||
// setSignOutOpen(false);
|
||||
// }
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!headerRef.current) return
|
||||
const update = () => {
|
||||
document.documentElement.style.setProperty('--navbar-h', `${headerRef.current.offsetHeight}px`)
|
||||
}
|
||||
update()
|
||||
const ro = new ResizeObserver(update)
|
||||
ro.observe(headerRef.current)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section id="philproperties-admin" className="min-h-screen flex flex-col">
|
||||
<TooltipProvider>
|
||||
<div className={cn('sticky top-0 z-50 bg-background')} >
|
||||
<div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="xs:hidden sm:block w-40 cursor-pointer" onClick={() => navigate(`/admin`)}>
|
||||
<img src="/philpro-white.png" alt="" className="object-cover" />
|
||||
{/* AdminProvider wraps header + body so UserMenu can access ProfileProvider */}
|
||||
<AdminProvider>
|
||||
<div ref={headerRef} className={cn('fixed top-0 z-50 w-full bg-background border-b')} >
|
||||
<div className="w-full flex items-center justify-between xs:px-4 lg:px-5 py-3">
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="xs:hidden sm:block w-40 cursor-pointer" onClick={() => navigate(`/admin`)}>
|
||||
<img src="/philpro-white.png" alt="" className="object-cover" />
|
||||
</div>
|
||||
<div>
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height="16"
|
||||
width="16"
|
||||
viewBox="0 0 16 16"
|
||||
strokeLinejoin="round"
|
||||
className="xs:hidden sm:block fill-slate-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
|
||||
<Badge variant={role.variant} className="xs:hidden md:block capitalize">
|
||||
{role.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height="16"
|
||||
width="16"
|
||||
viewBox="0 0 16 16"
|
||||
strokeLinejoin="round"
|
||||
className="xs:hidden sm:block fill-slate-300"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationBell />
|
||||
<UserMenu />
|
||||
</div>
|
||||
<div id="name" className="lg:-ml-1 font-medium text-sm flex items-center gap-2">
|
||||
<Badge variant={role.variant} className="xs:hidden md:block capitalize">
|
||||
{role.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── All admin contexts live here, scoped to admin routes only ── */}
|
||||
<AdminProvider>
|
||||
<div id="main-body" className="bg-slate-100 flex-1 flex flex-col">
|
||||
<div id="main-body" className="bg-slate-100 flex-1 flex flex-col" style={{ paddingTop: 'var(--navbar-h)' }}>
|
||||
<Outlet />
|
||||
<Toaster position="bottom-right" richColors />
|
||||
</div>
|
||||
</AdminProvider>
|
||||
|
||||
{/* Footer sits outside AdminProvider, at the bottom of the flex column */}
|
||||
{/* Footer sits outside AdminProvider intentionally */}
|
||||
<footer className="w-full py-4 px-5 text-right text-sm text-muted-foreground">
|
||||
© Philproperties, 2026
|
||||
</footer>
|
||||
|
||||
@@ -12,8 +12,8 @@ function scrollTo(sectionId) {
|
||||
export default function AdminDashboard() {
|
||||
return (
|
||||
<div>
|
||||
{/* ── Sticky tab bar — driven by the same array ── */}
|
||||
<div className="sticky z-10 bg-background border-b" style={{ top: 'var(--navbar-h)' }}>
|
||||
{/* ── Fixed tab bar — driven by the same array ── */}
|
||||
<div className="fixed z-10 w-full bg-background border-b" style={{ top: 'var(--navbar-h)' }}>
|
||||
<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">
|
||||
@@ -37,6 +37,7 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
|
||||
{/* ── Sections — same array, one DashboardGrid per entry ── */}
|
||||
<div style={{ paddingTop: '40px' }}>
|
||||
{ADMIN_SECTIONS.map((s) => (
|
||||
<div key={s.id} id={s.id} style={{ scrollMarginTop: 'calc(var(--navbar-h) + 40px)' }} className="min-h-64">
|
||||
{s.tiles.length > 0 ? (
|
||||
@@ -49,6 +50,7 @@ export default function AdminDashboard() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useUsers } from "@/contexts/AdminUserContext";
|
||||
import { House, RefreshCw, ChevronLeft, ChevronRight, ExternalLink, CalendarIcon } from "lucide-react";
|
||||
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
|
||||
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
||||
import { timeAgo } from "@/utils/timestamp.util";
|
||||
|
||||
const BREADCRUMB = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Activity Feed" },
|
||||
];
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
function toDateStr(d) {
|
||||
if (!d) return undefined;
|
||||
return d.toLocaleDateString("en-CA"); // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export default function ActivityFeed() {
|
||||
const navigate = useNavigate();
|
||||
const { fetchActivity, activity, activityPagination, loading } = useUsers();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [action, setAction] = useState("all");
|
||||
const [from, setFrom] = useState(null);
|
||||
const [to, setTo] = useState(null);
|
||||
|
||||
const load = useCallback(
|
||||
(p = 1) => {
|
||||
fetchActivity({
|
||||
page: p,
|
||||
limit: LIMIT,
|
||||
action: action === "all" ? undefined : action,
|
||||
from: toDateStr(from),
|
||||
to: toDateStr(to),
|
||||
});
|
||||
setPage(p);
|
||||
},
|
||||
[fetchActivity, action, from, to]
|
||||
);
|
||||
|
||||
useEffect(() => { load(1); }, [action, from, to]);
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 pb-10">
|
||||
|
||||
{/* ─── Header ────────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={BREADCRUMB} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Activity Feed</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All user actions across the system — {activityPagination.totalRecords} total
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => load(page)} disabled={loading}>
|
||||
<RefreshCw className={`size-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ─── Filters ───────────────────────────────────────────────── */}
|
||||
<div className="flex flex-wrap gap-3 bg-card border rounded-lg p-4">
|
||||
<div className="flex flex-col gap-1 min-w-[180px]">
|
||||
<span className="text-xs text-muted-foreground">Action</span>
|
||||
<Select value={action} onValueChange={setAction}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="All actions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All actions</SelectItem>
|
||||
{Object.entries(ACTION_CONFIG).map(([key, cfg]) => (
|
||||
<SelectItem key={key} value={key}>{cfg.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">From</span>
|
||||
<DatePickerButton value={from} onChange={setFrom} placeholder="Start date" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">To</span>
|
||||
<DatePickerButton value={to} onChange={setTo} placeholder="End date" disabled={from ? { before: from } : undefined} />
|
||||
</div>
|
||||
|
||||
{(action !== "all" || from || to) && (
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => { setAction("all"); setFrom(null); setTo(null); }}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Table ─────────────────────────────────────────────────── */}
|
||||
<div className="bg-card border rounded-lg overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b bg-muted/40">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">User</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Role</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Action</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Entity</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground text-xs uppercase tracking-wide">Time</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading ? (
|
||||
[...Array(8)].map((_, i) => (
|
||||
<tr key={i}>
|
||||
<td className="px-4 py-3"><Skeleton className="h-4 w-36" /></td>
|
||||
<td className="px-4 py-3"><Skeleton className="h-4 w-16" /></td>
|
||||
<td className="px-4 py-3"><Skeleton className="h-5 w-24 rounded-full" /></td>
|
||||
<td className="px-4 py-3"><Skeleton className="h-4 w-20" /></td>
|
||||
<td className="px-4 py-3"><Skeleton className="h-4 w-20" /></td>
|
||||
<td className="px-4 py-3" />
|
||||
</tr>
|
||||
))
|
||||
) : activity.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-muted-foreground text-sm">
|
||||
No activity found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
activity.map((row) => (
|
||||
<ActivityRow
|
||||
key={row.activity_id}
|
||||
row={row}
|
||||
onViewUser={() => navigate(`/admin/users/view/${row.user_id}`)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ─── Pagination ────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t bg-muted/20 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{activityPagination.totalRecords > 0
|
||||
? `Page ${activityPagination.page} of ${activityPagination.totalPages} · ${activityPagination.totalRecords} total`
|
||||
: "No results"}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline" size="icon" className="h-7 w-7"
|
||||
disabled={!activityPagination.hasPrevPage || loading}
|
||||
onClick={() => load(activityPagination.page - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="icon" className="h-7 w-7"
|
||||
disabled={!activityPagination.hasNextPage || loading}
|
||||
onClick={() => load(activityPagination.page + 1)}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── DatePickerButton ─────────────────────────────────────────────────────────
|
||||
function DatePickerButton({ value, onChange, placeholder, disabled }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const label = value
|
||||
? value.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||
: placeholder;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`h-8 text-sm w-[150px] justify-start font-normal gap-2 ${!value ? "text-muted-foreground" : ""}`}
|
||||
>
|
||||
<CalendarIcon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={(d) => { onChange(d ?? null); setOpen(false); }}
|
||||
disabled={disabled}
|
||||
initialFocus
|
||||
/>
|
||||
<div className="border-t px-3 py-2 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs"
|
||||
onClick={() => { onChange(new Date()); setOpen(false); }}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
{value && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-xs text-muted-foreground"
|
||||
onClick={() => { onChange(null); setOpen(false); }}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Row ──────────────────────────────────────────────────────────────────────
|
||||
function initials(name, email) {
|
||||
if (name) return name.split(" ").map((n) => n[0]).slice(0, 2).join("").toUpperCase();
|
||||
return (email?.[0] ?? "?").toUpperCase();
|
||||
}
|
||||
|
||||
function ActivityRow({ row, onViewUser }) {
|
||||
const { label, className } = getActionBadge(row.action);
|
||||
const ts = row.created_at;
|
||||
const displayName = row.full_name ?? row.email ?? `User #${row.user_id}`;
|
||||
|
||||
return (
|
||||
<tr className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar size="sm" className="shrink-0">
|
||||
<AvatarImage src={row.avatar_url ?? undefined} alt={row.full_name ?? row.email} />
|
||||
<AvatarFallback className="text-xs font-semibold bg-secondary text-secondary-foreground">
|
||||
{initials(row.full_name, row.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-medium text-sm truncate max-w-[200px]">{displayName}</span>
|
||||
{row.full_name && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[200px]">{row.email}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge variant="outline" className="capitalize text-xs">{row.acc_type ?? "—"}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">
|
||||
{row.entity_type
|
||||
? <span className="capitalize">{row.entity_type}{row.entity_id ? ` #${row.entity_id}` : ""}</span>
|
||||
: <span className="text-muted-foreground/50">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs whitespace-nowrap">
|
||||
{ts ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{new Date(ts).toLocaleString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
hour: "numeric", minute: "2-digit", second: "2-digit",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="opacity-50 hover:opacity-100" onClick={onViewUser}>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>View user</TooltipContent>
|
||||
</Tooltip>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useUsers } from "@/contexts/AdminUserContext";
|
||||
import { House, ArrowLeft, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
import { ACTION_CONFIG, getActionBadge } from "@/data/activity.data";
|
||||
import { timeAgo } from "@/utils/timestamp.util";
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
export default function UserActivityPage() {
|
||||
const { userId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, fetchUser, fetchUserActivity, activity, activityPagination, activityLoading } = useUsers();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [action, setAction] = useState("all");
|
||||
|
||||
const load = useCallback(
|
||||
(p = 1) => {
|
||||
fetchUserActivity(userId, {
|
||||
page: p,
|
||||
limit: LIMIT,
|
||||
action: action === "all" ? undefined : action,
|
||||
});
|
||||
setPage(p);
|
||||
},
|
||||
[fetchUserActivity, userId, action]
|
||||
);
|
||||
|
||||
useEffect(() => { fetchUser(userId); }, [userId]);
|
||||
useEffect(() => { load(1); }, [action, userId]);
|
||||
|
||||
const displayName = user?.personal_info?.name?.full_name ?? user?.email ?? `User #${userId}`;
|
||||
|
||||
const breadcrumb = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Users", to: "/admin/users" },
|
||||
{ label: displayName, to: `/admin/users/view/${userId}` },
|
||||
{ label: "Activity" },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 min-h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-start px-4 pb-10">
|
||||
|
||||
{/* ─── Header ──────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-2 my-6">
|
||||
<AppBreadcrumb items={breadcrumb} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate(`/admin/users/view/${userId}`)}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Activity — {displayName}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activityPagination.totalRecords} event{activityPagination.totalRecords !== 1 ? "s" : ""} recorded
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => load(page)} disabled={activityLoading}>
|
||||
<RefreshCw className={`size-4 mr-2 ${activityLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ─── Filter ──────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 bg-card border rounded-lg p-4">
|
||||
<div className="flex flex-col gap-1 min-w-[180px]">
|
||||
<span className="text-xs text-muted-foreground">Filter by action</span>
|
||||
<Select value={action} onValueChange={setAction}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="All actions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All actions</SelectItem>
|
||||
{Object.entries(ACTION_CONFIG).map(([key, cfg]) => (
|
||||
<SelectItem key={key} value={key}>{cfg.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{action !== "all" && (
|
||||
<div className="flex items-end pt-5">
|
||||
<Button variant="ghost" size="sm" className="h-8 text-xs" onClick={() => setAction("all")}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── Timeline ────────────────────────────────────────────── */}
|
||||
<div className="bg-card border rounded-lg overflow-hidden">
|
||||
{activityLoading ? (
|
||||
<div className="p-6 space-y-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-5 w-24 rounded-full shrink-0" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-4 w-28 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : activity.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-muted-foreground text-sm">
|
||||
No activity recorded{action !== "all" ? " for this filter" : ""}.
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{activity.map((row) => (
|
||||
<ActivityItem key={row.activity_id} row={row} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Pagination ──────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t bg-muted/20 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{activityPagination.totalRecords > 0
|
||||
? `Page ${activityPagination.page} of ${activityPagination.totalPages} · ${activityPagination.totalRecords} total`
|
||||
: "No results"}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline" size="icon" className="h-7 w-7"
|
||||
disabled={!activityPagination.hasPrevPage || activityLoading}
|
||||
onClick={() => load(activityPagination.page - 1)}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="icon" className="h-7 w-7"
|
||||
disabled={!activityPagination.hasNextPage || activityLoading}
|
||||
onClick={() => load(activityPagination.page + 1)}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Item ──────────────────────────────────────────────────────────────────────
|
||||
function ActivityItem({ row }) {
|
||||
const { label, className } = getActionBadge(row.action);
|
||||
const ts = row.created_at;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-5 py-3 hover:bg-muted/30 transition-colors">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border shrink-0 ${className}`}>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{row.entity_type ? (
|
||||
<span className="text-xs text-muted-foreground capitalize">
|
||||
{row.entity_type}{row.entity_id ? ` #${row.entity_id}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground/40">—</span>
|
||||
)}
|
||||
|
||||
{row.details && Object.keys(row.details).length > 0 && (
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline truncate max-w-xs">
|
||||
{Object.entries(row.details)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(" · ")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="ml-auto text-xs whitespace-nowrap shrink-0">
|
||||
{ts ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-muted-foreground cursor-default">{timeAgo(ts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{new Date(ts).toLocaleString("en-US", {
|
||||
month: "short", day: "numeric", year: "numeric",
|
||||
hour: "numeric", minute: "2-digit", second: "2-digit",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-muted-foreground/40">—</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// modules/admin/pages/advertisements/AddAdvertisement.jsx
|
||||
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { House, Plus, Trash2, ImagePlus } from "lucide-react";
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
|
||||
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
|
||||
|
||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const schema = z.object({
|
||||
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
|
||||
badge_label: z.string().optional(),
|
||||
headline: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
image_asset_id: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
// Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard)
|
||||
ctas: z.array(z.object({
|
||||
label: z.string().min(1, "Label is required."),
|
||||
link: z.string().min(1, "Link is required."),
|
||||
variant: z.enum(["default", "outline"]).default("default"),
|
||||
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
is_active: z.boolean().default(true),
|
||||
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.too_big,
|
||||
maximum: 200,
|
||||
type: "string",
|
||||
inclusive: true,
|
||||
message: "Description must be 200 characters or fewer for hero placements.",
|
||||
path: ["description"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, description, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{(title || description) && (
|
||||
<div className="space-y-0.5 pb-1 border-b">
|
||||
{title && <h2 className="text-sm font-semibold">{title}</h2>}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BANNER_SIZES = [
|
||||
{ value: "sm", label: "Small" },
|
||||
{ value: "md", label: "Medium" },
|
||||
{ value: "lg", label: "Large" },
|
||||
];
|
||||
|
||||
const CTA_VARIANTS = [
|
||||
{ value: "default", label: "Primary" },
|
||||
{ value: "outline", label: "Outline" },
|
||||
];
|
||||
|
||||
// ─── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AddAdvertisement() {
|
||||
const navigate = useNavigate();
|
||||
const { createAdvertisement, loading } = useAdvertisements();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: undefined,
|
||||
badge_label: "",
|
||||
headline: "",
|
||||
description: "",
|
||||
image_asset_id: null,
|
||||
ctas: [],
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
order: 0,
|
||||
is_active: true,
|
||||
size: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
||||
|
||||
const type = watch("type");
|
||||
const description = watch("description");
|
||||
const showRichContent = RICH_CONTENT_TYPES.includes(type);
|
||||
const isBanner = type === "banner";
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Advertisements", to: "/admin/advertisements" },
|
||||
{ label: "New" },
|
||||
];
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const payload = {
|
||||
...values,
|
||||
image_asset_id: values.image_asset_id || null,
|
||||
start_date: values.start_date || null,
|
||||
end_date: values.end_date || null,
|
||||
size: values.type === "banner" ? (values.size || "md") : null,
|
||||
createdBy: user?.user_id ?? null,
|
||||
};
|
||||
|
||||
const res = await createAdvertisement(payload);
|
||||
if (res) navigate("/admin/advertisements");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
|
||||
<div className="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-2xl pb-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">New advertisement</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">Create a banner, popup, or hero placement.</p>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
<SectionCard title="Placement" description="Where this advertisement will appear.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ADVERTISEMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.type?.message} />
|
||||
{type && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isBanner && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Size</Label>
|
||||
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BANNER_SIZES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Controls the banner's height. Width always stretches full-width.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showRichContent && (
|
||||
<SectionCard title="Content" description="Headline, description, and badge text shown on the hero placement.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Badge label</Label>
|
||||
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline</Label>
|
||||
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Description</Label>
|
||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||
{type === "hero" && (
|
||||
<div className="flex justify-between items-start mt-1">
|
||||
<FieldError message={errors.description?.message} />
|
||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{description?.length ?? 0}/200
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{!showRichContent && (
|
||||
<SectionCard title="Content" description="Optional headline for this placement.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline (optional)</Label>
|
||||
<Input placeholder="Internal label for this ad" {...register("headline")} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
|
||||
{selectedAsset ? (
|
||||
<div
|
||||
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<img
|
||||
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
|
||||
alt={selectedAsset.display_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||
<span className="text-white text-sm opacity-0 group-hover:opacity-100">Change image</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="w-full h-36 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ImagePlus className="size-5" />
|
||||
<span className="text-sm">Select an image</span>
|
||||
</button>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showRichContent && (
|
||||
<SectionCard
|
||||
title="Calls to action"
|
||||
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or Outline.`}
|
||||
>
|
||||
{ctaFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.label?.message} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.link?.message} />
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Select
|
||||
value={watch(`ctas.${index}.variant`) ?? "default"}
|
||||
onValueChange={(v) => setValue(`ctas.${index}.variant`, v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CTA_VARIANTS.map((v) => (
|
||||
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{ctaFields.length < MAX_CTAS ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add CTA
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Start date</Label>
|
||||
<Input type="datetime-local" {...register("start_date")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">End date</Label>
|
||||
<Input type="datetime-local" {...register("end_date")} />
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Display" description="Manual ordering and on/off switch.">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Order</Label>
|
||||
<Input type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
||||
<Label className="text-sm">Active</Label>
|
||||
<Switch
|
||||
checked={watch("is_active")}
|
||||
onCheckedChange={(v) => setValue("is_active", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<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" />}
|
||||
Create advertisement
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="image"
|
||||
onSelect={(asset) => {
|
||||
setSelectedAsset(asset);
|
||||
setValue("image_asset_id", asset.asset_id, { shouldValidate: true });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// modules/admin/pages/advertisements/AdvertisementList.jsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { House, Plus, Search, Megaphone, MousePointerClick, Edit, Trash2 } from "lucide-react";
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
import { ADVERTISEMENT_TYPES, ADVERTISEMENT_TYPE_MAP, ADVERTISEMENT_STATUSES, ADVERTISEMENT_STATUS_MAP } from "@/data/advertisement.data";
|
||||
|
||||
export default function AdvertisementList() {
|
||||
const navigate = useNavigate();
|
||||
const { advertisements, pagination, loading, fetchAdvertisements, archiveAdvertisement } = useAdvertisements();
|
||||
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const filters = [];
|
||||
if (typeFilter !== "all") filters.push({ field: "type", value: typeFilter });
|
||||
if (statusFilter !== "all") filters.push({ field: "status", value: statusFilter });
|
||||
if (search.trim()) filters.push({ field: "headline", op: "ilike", value: search.trim() });
|
||||
|
||||
fetchAdvertisements({ page: 1, limit: 24, filters });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [typeFilter, statusFilter, search]);
|
||||
|
||||
const items = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Advertisements" },
|
||||
];
|
||||
|
||||
const total = pagination?.totalRecords ?? advertisements.length;
|
||||
const activeCount = advertisements.filter((a) => a.status === "active").length;
|
||||
const scheduledCount = advertisements.filter((a) => a.status === "scheduled").length;
|
||||
const expiredCount = advertisements.filter((a) => a.status === "expired").length;
|
||||
|
||||
async function handleArchive(advertisementId) {
|
||||
await archiveAdvertisement(advertisementId);
|
||||
}
|
||||
|
||||
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 w-full">
|
||||
<AppBreadcrumb items={items} />
|
||||
</div>
|
||||
|
||||
<div className="w-full flex flex-col gap-6 pb-10">
|
||||
|
||||
{/* ── Header ─────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Advertisements</h1>
|
||||
<p className="text-sm text-muted-foreground">Manage public-facing banners, popups, and promotional placements</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/admin/advertisements/add")}>
|
||||
<Plus className="size-4" />
|
||||
New advertisement
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Stat cards ─────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard label="Total ads" value={total} />
|
||||
<StatCard label="Active" value={activeCount} tone="success" />
|
||||
<StatCard label="Scheduled" value={scheduledCount} tone="info" />
|
||||
<StatCard label="Expired" value={expiredCount} tone="muted" />
|
||||
</div>
|
||||
|
||||
{/* ── Filters ────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="All types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All types</SelectItem>
|
||||
{ADVERTISEMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{ADVERTISEMENT_STATUSES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="relative flex-1 min-w-[160px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search advertisements..."
|
||||
className="pl-8"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Grid ───────────────────────────────────────────────────── */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
) : advertisements.length === 0 ? (
|
||||
<EmptyState onCreate={() => navigate("/admin/advertisements/add")} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{advertisements.map((ad) => (
|
||||
<AdvertisementCard
|
||||
key={ad.advertisement_id}
|
||||
ad={ad}
|
||||
onView={() => navigate(`/admin/advertisements/${ad.advertisement_id}/view`)}
|
||||
onEdit={() => navigate(`/admin/advertisements/${ad.advertisement_id}/edit`)}
|
||||
onArchive={() => handleArchive(ad.advertisement_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat card ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, tone = "default" }) {
|
||||
const toneClass = {
|
||||
default: "text-foreground",
|
||||
success: "text-green-600 dark:text-green-400",
|
||||
info: "text-blue-600 dark:text-blue-400",
|
||||
muted: "text-muted-foreground",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<div className="bg-background rounded-lg border p-4">
|
||||
<p className="text-sm text-muted-foreground mb-1">{label}</p>
|
||||
<p className={`text-2xl font-semibold ${toneClass}`}>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Advertisement card ─────────────────────────────────────────────────────
|
||||
|
||||
function AdvertisementCard({ ad, onView, onEdit, onArchive }) {
|
||||
const typeMeta = ADVERTISEMENT_TYPE_MAP[ad.type] ?? {};
|
||||
const statusMeta = ADVERTISEMENT_STATUS_MAP[ad.status] ?? {};
|
||||
const TypeIcon = typeMeta.icon ?? Megaphone;
|
||||
|
||||
const previewSrc = ad.image?.thumbnail_url || ad.image?.file_url || ad.image_url || null;
|
||||
const isDimmed = ad.status === "expired" || ad.status === "archived";
|
||||
|
||||
const dateRange = formatDateRange(ad.start_date, ad.end_date);
|
||||
|
||||
return (
|
||||
<div className={`bg-background rounded-lg border overflow-hidden flex flex-col ${isDimmed ? "opacity-70" : ""}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onView}
|
||||
className="h-32 bg-muted relative flex items-center justify-center w-full text-left cursor-pointer"
|
||||
aria-label="View advertisement details"
|
||||
>
|
||||
{previewSrc ? (
|
||||
<img src={previewSrc} alt={ad.headline || ad.type} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<Megaphone className="size-7 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
<span className={`absolute top-2 left-2 text-xs font-medium px-2 py-0.5 rounded-md ${statusMeta.badgeClass ?? "bg-muted text-muted-foreground"}`}>
|
||||
{statusMeta.label ?? ad.status}
|
||||
</span>
|
||||
<span className="absolute top-2 right-2 flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-md bg-black/55 text-white">
|
||||
<TypeIcon className="size-3" />
|
||||
{typeMeta.label ?? ad.type}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="p-3 flex flex-col gap-2 flex-1">
|
||||
<button type="button" onClick={onView} className="text-left">
|
||||
<p className="text-sm font-medium leading-snug truncate hover:underline">{ad.headline || ad.badge_label || "Untitled advertisement"}</p>
|
||||
{dateRange && <p className="text-xs text-muted-foreground mt-0.5">{dateRange}</p>}
|
||||
</button>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between text-xs text-muted-foreground pt-2">
|
||||
<span className="flex items-center gap-1">
|
||||
<MousePointerClick className="size-3.5" />
|
||||
{ad.click_count ?? 0} clicks
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={onEdit} aria-label="Edit">
|
||||
<Edit className="size-3.5" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-7" aria-label="Delete">
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive this advertisement?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{ad.headline || ad.badge_label || "This advertisement"}" will be moved to archived advertisements. You can restore it later.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onArchive}>Archive</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Empty state ────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({ onCreate }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center border rounded-lg bg-background">
|
||||
<Megaphone className="size-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">No advertisements yet</p>
|
||||
<p className="text-sm text-muted-foreground">Create your first banner, popup, or hero placement.</p>
|
||||
</div>
|
||||
<Button onClick={onCreate}>
|
||||
<Plus className="size-4" />
|
||||
New advertisement
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDateRange(start, end) {
|
||||
if (!start && !end) return null;
|
||||
const fmt = (d) => new Date(d).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||
|
||||
if (start && end) return `${fmt(start)} - ${fmt(end)}`;
|
||||
if (start) return `Starts ${fmt(start)}`;
|
||||
if (end) return `Ends ${fmt(end)}`;
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
// modules/admin/pages/advertisements/EditAdvertisement.jsx
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { House, Plus, Trash2, ImagePlus } from "lucide-react";
|
||||
|
||||
import { useAdvertisements } from "@/contexts/AdminAdvertisementContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import AppBreadcrumb from "@/components/generic/Breadcrumb/AppBreadcrumb";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { AssetPickerSheet } from "@/components/generic/AssetPickerSheet";
|
||||
|
||||
import { ADVERTISEMENT_TYPES, RICH_CONTENT_TYPES, MAX_CTAS } from "@/data/advertisement.data";
|
||||
|
||||
// ─── Schema ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const schema = z.object({
|
||||
type: z.enum(["hero", "banner", "popup", "sidebar"], { required_error: "Type is required." }),
|
||||
badge_label: z.string().optional(),
|
||||
headline: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
image_asset_id: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
// Hard cap of 2 CTAs per advertisement (max() backs up the UI-level append guard)
|
||||
ctas: z.array(z.object({
|
||||
label: z.string().min(1, "Label is required."),
|
||||
link: z.string().min(1, "Link is required."),
|
||||
variant: z.enum(["default", "outline"]).default("default"),
|
||||
})).max(MAX_CTAS, `A maximum of ${MAX_CTAS} buttons is allowed.`).default([]),
|
||||
start_date: z.string().optional(),
|
||||
end_date: z.string().optional(),
|
||||
order: z.coerce.number().min(0).default(0),
|
||||
is_active: z.boolean().default(true),
|
||||
size: z.enum(["sm", "md", "lg"]).nullable().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.type === "hero" && (data.description?.length ?? 0) > 200) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.too_big,
|
||||
maximum: 200,
|
||||
type: "string",
|
||||
inclusive: true,
|
||||
message: "Description must be 200 characters or fewer for hero placements.",
|
||||
path: ["description"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldError({ message }) {
|
||||
if (!message) return null;
|
||||
return <p className="text-xs text-destructive mt-1">{message}</p>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, description, children }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-6 space-y-5">
|
||||
{(title || description) && (
|
||||
<div className="space-y-0.5 pb-1 border-b">
|
||||
{title && <h2 className="text-sm font-semibold">{title}</h2>}
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Convert ISO datetime to value usable by <input type="datetime-local">
|
||||
function toLocalInputValue(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const BANNER_SIZES = [
|
||||
{ value: "sm", label: "Small" },
|
||||
{ value: "md", label: "Medium" },
|
||||
{ value: "lg", label: "Large" },
|
||||
];
|
||||
|
||||
const CTA_VARIANTS = [
|
||||
{ value: "default", label: "Primary" },
|
||||
{ value: "outline", label: "Outline" },
|
||||
];
|
||||
|
||||
// ─── Page ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function EditAdvertisement() {
|
||||
const navigate = useNavigate();
|
||||
const { advertisementId } = useParams();
|
||||
const { fetchAdvertisement, updateAdvertisement, loading } = useAdvertisements();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [selectedAsset, setSelectedAsset] = useState(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: undefined,
|
||||
badge_label: "",
|
||||
headline: "",
|
||||
description: "",
|
||||
image_asset_id: null,
|
||||
ctas: [],
|
||||
start_date: "",
|
||||
end_date: "",
|
||||
order: 0,
|
||||
is_active: true,
|
||||
size: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { fields: ctaFields, append: appendCta, remove: removeCta } = useFieldArray({ control, name: "ctas" });
|
||||
|
||||
const type = watch("type");
|
||||
const description = watch("description");
|
||||
const showRichContent = RICH_CONTENT_TYPES.includes(type);
|
||||
const isBanner = type === "banner";
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ label: "Home", icon: <House className="size-4" />, to: "/admin" },
|
||||
{ label: "Advertisements", to: "/admin/advertisements" },
|
||||
{ label: "Edit" },
|
||||
];
|
||||
|
||||
// ─── Load existing advertisement data ────────────────────────────────────
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const res = await fetchAdvertisement(advertisementId);
|
||||
const ad = res?.data?.data ?? null;
|
||||
if (!ad) return;
|
||||
|
||||
if (ad.image) setSelectedAsset(ad.image);
|
||||
|
||||
reset({
|
||||
type: ad.type ?? undefined,
|
||||
badge_label: ad.badge_label ?? "",
|
||||
headline: ad.headline ?? "",
|
||||
description: ad.description ?? "",
|
||||
image_asset_id: ad.image?.asset_id ?? null,
|
||||
ctas: (ad.ctas ?? []).map((c, i) => ({
|
||||
label: c.label ?? "",
|
||||
link: c.link ?? "",
|
||||
variant: c.variant ?? (i === 0 ? "default" : "outline"),
|
||||
})),
|
||||
start_date: toLocalInputValue(ad.start_date),
|
||||
end_date: toLocalInputValue(ad.end_date),
|
||||
order: ad.order ?? 0,
|
||||
is_active: ad.is_active ?? true,
|
||||
size: ad.size ?? null,
|
||||
});
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [advertisementId]);
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
if (!isDirty) return navigate(-1);
|
||||
|
||||
const payload = {
|
||||
...values,
|
||||
image_asset_id: values.image_asset_id || null,
|
||||
start_date: values.start_date || null,
|
||||
end_date: values.end_date || null,
|
||||
size: values.type === "banner" ? (values.size || "md") : null,
|
||||
updatedBy: user?.user_id ?? null,
|
||||
};
|
||||
|
||||
const res = await updateAdvertisement(advertisementId, payload);
|
||||
if (res) navigate("/admin/advertisements");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-muted/60 h-full">
|
||||
<div className="lg:container lg:mx-auto lg:px-0 flex flex-col items-center px-4">
|
||||
<div className="flex flex-col gap-2 my-6 w-full">
|
||||
<AppBreadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-2xl pb-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight mb-1">Edit advertisement</h1>
|
||||
<p className="text-sm text-muted-foreground mb-6">Update this banner, popup, or hero placement.</p>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
<SectionCard title="Placement" description="Where this advertisement will appear.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setValue("type", v, { shouldValidate: true, shouldDirty: true })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ADVERTISEMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError message={errors.type?.message} />
|
||||
{type && (
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{ADVERTISEMENT_TYPES.find((t) => t.value === type)?.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isBanner && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Size</Label>
|
||||
<Select value={watch("size") ?? "md"} onValueChange={(v) => setValue("size", v, { shouldDirty: true })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BANNER_SIZES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
Controls the banner's height. Width always stretches full-width.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showRichContent && (
|
||||
<SectionCard title="Content" description="Headline, description, and badge text shown on the hero placement.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Badge label</Label>
|
||||
<Input placeholder="e.g. Advertisement" {...register("badge_label")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline</Label>
|
||||
<Input placeholder="e.g. Discover the World Top Designers" {...register("headline")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Description</Label>
|
||||
<Textarea rows={3} placeholder="Supporting text under the headline" {...register("description")} />
|
||||
{type === "hero" && (
|
||||
<div className="flex justify-between items-start mt-1">
|
||||
<FieldError message={errors.description?.message} />
|
||||
<span className={`text-xs ml-auto ${(description?.length ?? 0) > 200 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{description?.length ?? 0}/200
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{!showRichContent && (
|
||||
<SectionCard title="Content" description="Optional headline for this placement.">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Headline (optional)</Label>
|
||||
<Input placeholder="Internal label for this ad" {...register("headline")} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title="Image" description="Choose an existing asset from Asset Management.">
|
||||
{selectedAsset ? (
|
||||
<div
|
||||
className="relative rounded-lg overflow-hidden cursor-pointer border h-36 group"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<img
|
||||
src={selectedAsset.thumbnail_url || selectedAsset.file_url}
|
||||
alt={selectedAsset.display_name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
|
||||
<span className="text-white text-sm opacity-0 group-hover:opacity-100">Change image</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
className="w-full h-36 rounded-lg border border-dashed flex flex-col items-center justify-center gap-2 text-muted-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<ImagePlus className="size-5" />
|
||||
<span className="text-sm">Select an image</span>
|
||||
</button>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{showRichContent && (
|
||||
<SectionCard
|
||||
title="Calls to action"
|
||||
description={`Up to ${MAX_CTAS} buttons shown on the placement. Each button can be styled as Primary or Outline.`}
|
||||
>
|
||||
{ctaFields.map((field, index) => (
|
||||
<div key={field.id} className="flex gap-2 items-start">
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Label (e.g. Explore)" {...register(`ctas.${index}.label`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.label?.message} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input placeholder="Link (e.g. /courses)" {...register(`ctas.${index}.link`)} />
|
||||
<FieldError message={errors.ctas?.[index]?.link?.message} />
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Select
|
||||
value={watch(`ctas.${index}.variant`) ?? "default"}
|
||||
onValueChange={(v) => setValue(`ctas.${index}.variant`, v, { shouldDirty: true })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Style" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CTA_VARIANTS.map((v) => (
|
||||
<SelectItem key={v.value} value={v.value}>{v.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeCta(index)} aria-label="Remove">
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{ctaFields.length < MAX_CTAS ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => appendCta({ label: "", link: "", variant: ctaFields.length === 0 ? "default" : "outline" })}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add CTA
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Maximum of {MAX_CTAS} buttons reached.</p>
|
||||
)}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<SectionCard title="Scheduling" description="Optional start and end dates for this placement.">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Start date</Label>
|
||||
<Input type="datetime-local" {...register("start_date")} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block">End date</Label>
|
||||
<Input type="datetime-local" {...register("end_date")} />
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="Display" description="Manual ordering and on/off switch.">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block">Order</Label>
|
||||
<Input type="number" min={0} {...register("order")} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between border rounded-md px-3 h-9">
|
||||
<Label className="text-sm">Active</Label>
|
||||
<Switch
|
||||
checked={watch("is_active")}
|
||||
onCheckedChange={(v) => setValue("is_active", v, { shouldDirty: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<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" />}
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AssetPickerSheet
|
||||
open={pickerOpen}
|
||||
onOpenChange={setPickerOpen}
|
||||
fileType="image"
|
||||
onSelect={(asset) => {
|
||||
setSelectedAsset(asset);
|
||||
setValue("image_asset_id", asset.asset_id, { shouldValidate: true, shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user