merge new_starr_app@qas history into apps/web

git-subtree-dir: apps/web
git-subtree-mainline: eaa6d2276a
git-subtree-split: 459af9d46a
This commit is contained in:
2026-08-24 12:21:16 +08:00
699 changed files with 103969 additions and 0 deletions
+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect } from 'react';
import { AuthProvider, useAuth, decodeToken } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { DateTimePreferenceProvider } from './contexts/DateTimePreferenceContext';
import { Helmet, HelmetProvider } from "react-helmet-async";
import { TooltipProvider } from './components/ui/tooltip';
import { setAuthInterceptor } from './utils/api.util';
import { attachCsrfInterceptor, fetchCsrfToken } from './utils/csrf.util';
import AppRouter from './routes/AppRouter';
import AppLoadingScreen from './components/AppLoadingScreen';
import './index.css';
import 'react-photo-view/dist/react-photo-view.css';
function AppWithAuth() {
const { accessTokenRef, setAccessToken, setUser, restoreSession, logout, loading } = useAuth()
useEffect(() => {
attachCsrfInterceptor()
fetchCsrfToken()
setAuthInterceptor(
() => accessTokenRef.current,
(newToken) => {
if (newToken) {
setAccessToken(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()
}, [])
if (loading) return <AppLoadingScreen />
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="system" storageKey="vite-ui-theme">
<DateTimePreferenceProvider>
<TooltipProvider delayDuration={300}>
<AuthProvider>
<AppWithAuth />
</AuthProvider>
</TooltipProvider>
</DateTimePreferenceProvider>
</ThemeProvider>
</HelmetProvider>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,12 @@
import { Spinner } from './ui/spinner'
const APP_NAME = import.meta.env.VITE_APP_NAME ?? 'STARR'
export default function AppLoadingScreen() {
return (
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-background">
<Spinner className="size-10 text-primary" />
<p className="mt-4 text-sm text-muted-foreground tracking-wide">Loading {APP_NAME}...</p>
</div>
)
}
@@ -0,0 +1,97 @@
// components/admin/advertisements/AdvertisementPreview.jsx
import { ChevronLeft, ChevronRight, Megaphone } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
// ── AdvertisementPreview ─────────────────────────────────────────────────────
/**
* Live, presentational preview of a single ad slide as it will actually
* render inside the real Hero/Banner carousel (see components/generic/Blocks/
* Client/Advertisements/{Hero,Banner}.jsx) — same image + gradient overlay +
* bottom-left badge/headline/description layout, just scaled down for the
* admin form. The prev/next/progress chrome below the slide is static (no
* real carousel behind it, since there's only ever one slide to preview) —
* it's there purely so the admin recognizes this as "inside a carousel."
*
* Props:
* format — "hero" | "banner", determines slide height/typography
* badgeLabels — string[]
* headline — string
* description — string
* imageSrc — resolved image URL, or null/undefined
*/
export function AdvertisementPreview({ format = "hero", badgeLabels, headline, description, imageSrc }) {
const isHero = format !== "banner";
const labels = Array.isArray(badgeLabels) ? badgeLabels.filter(Boolean) : [];
return (
<div>
<p className="text-xs text-muted-foreground mb-2">
Preview — this is how it will appear in the carousel.
</p>
<Card className={cnRounded(isHero)}>
<CardContent className={cnHeight(isHero) + " relative bg-muted flex items-center justify-center p-0"}>
{imageSrc ? (
<img
src={imageSrc}
alt={headline || "Advertisement preview"}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<Megaphone className="size-6 text-muted-foreground" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
<div className="absolute bottom-0 left-0 p-4 flex flex-col gap-2 max-w-[85%]">
{labels.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{labels.map((label, i) => (
<Badge key={i} variant="outline" className="pointer-events-none select-none w-fit border-white/30 bg-black/20 text-white">
<Megaphone /> {label}
</Badge>
))}
</div>
)}
{headline && (
<div className={(isHero ? "text-xl" : "text-lg") + " font-bold text-white leading-tight tracking-tight"}>
{headline}
</div>
)}
{description && (
<p className="text-gray-200 text-xs leading-relaxed line-clamp-2">
{description}
</p>
)}
</div>
</CardContent>
</Card>
{/* Static carousel chrome — communicates "this sits inside a carousel," not functional */}
<div className="flex items-center justify-between mt-3">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon-sm" className="rounded-lg" disabled>
<ChevronLeft />
</Button>
<Button variant="outline" size="icon-sm" className="rounded-lg" disabled>
<ChevronRight />
</Button>
</div>
<div className="flex-1 max-w-24 ml-4">
<Progress value={100} className="h-2" />
</div>
</div>
</div>
);
}
function cnRounded(isHero) {
return "border overflow-hidden pl-0 py-0 " + (isHero ? "rounded-2xl" : "rounded-xl");
}
function cnHeight(isHero) {
return isHero ? "h-56" : "h-48";
}
+69
View File
@@ -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 };
@@ -0,0 +1,42 @@
import * as React from "react"
import { ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
export function InteractiveHoverButton({
children,
className,
...props
}) {
// Split text and icons for consistent layout
const childArray = React.Children.toArray(children)
const hasIcon = childArray.length > 1
return (
<button
className={cn(
"group bg-background relative w-auto cursor-pointer overflow-hidden rounded-full border p-2 px-6 text-center font-medium transition-all disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}>
{/* Normal State */}
<div className="flex items-center justify-start gap-2">
<div className="bg-primary size-2 rounded-full transition-all duration-300 group-hover:scale-[300]" />
<span className="inline-flex items-center gap-2 transition-all duration-300 group-hover:translate-x-12 group-hover:opacity-0">
{childArray.map((child, index) => (
<React.Fragment key={index}>{child}</React.Fragment>
))}
</span>
</div>
{/* Hover State */}
<div className="text-primary-foreground absolute top-0 z-10 flex h-full w-full translate-x-12 items-center justify-center gap-2 opacity-0 transition-all duration-300 group-hover:-translate-x-5 group-hover:opacity-100">
<span className="inline-flex items-center gap-2">
{childArray.map((child, index) => (
<React.Fragment key={index}>{child}</React.Fragment>
))}
</span>
<ArrowRight className="size-4 shrink-0 xs:hidden lg:block" />
</div>
</button>
)
}
@@ -0,0 +1,60 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props} />
);
}
function TabsList({
className,
...props
}) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props} />
);
}
function TabsTrigger({
className,
...props
}) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props} />
);
}
function TabsContent({
className,
...props
}) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props} />
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,74 @@
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils"
export const RippleButton = React.forwardRef((
{
className,
children,
rippleColor = "#ffffff",
duration = "600ms",
onClick,
...props
},
ref
) => {
const [buttonRipples, setButtonRipples] = useState([])
const handleClick = (event) => {
createRipple(event)
onClick?.(event)
}
const createRipple = (event) => {
const button = event.currentTarget
const rect = button.getBoundingClientRect()
const size = Math.max(rect.width, rect.height)
const x = event.clientX - rect.left - size / 2
const y = event.clientY - rect.top - size / 2
const newRipple = { x, y, size, key: Date.now() }
setButtonRipples((prevRipples) => [...prevRipples, newRipple])
}
useEffect(() => {
if (buttonRipples.length > 0) {
const lastRipple = buttonRipples[buttonRipples.length - 1]
const timeout = setTimeout(() => {
setButtonRipples((prevRipples) =>
prevRipples.filter((ripple) => ripple.key !== lastRipple.key))
}, parseInt(duration))
return () => clearTimeout(timeout);
}
}, [buttonRipples, duration])
return (
<button
className={cn(
"bg-background text-primary relative flex cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 px-4 py-2 text-center",
className
)}
onClick={handleClick}
ref={ref}
{...props}>
<div className="relative z-10">{children}</div>
<span className="pointer-events-none absolute inset-0">
{buttonRipples.map((ripple) => (
<span
className="animate-rippling bg-background absolute rounded-full opacity-30"
key={ripple.key}
style={{
width: `${ripple.size}px`,
height: `${ripple.size}px`,
top: `${ripple.y}px`,
left: `${ripple.x}px`,
backgroundColor: rippleColor,
transform: `scale(0)`,
}} />
))}
</span>
</button>
);
})
RippleButton.displayName = "RippleButton"
@@ -0,0 +1,93 @@
// components/generic/CMS/AddBlockMenu.jsx
import { Plus, Type, Image, ImagePlay, Video, VideoIcon, Music2, Code2, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const BLOCK_TYPES = [
{
type: "text",
label: "Text",
description: "A rich text block",
icon: <Type className="h-4 w-4" />,
},
{
type: "image",
label: "Image",
description: "A single image",
icon: <Image className="h-4 w-4" />,
},
{
type: "text-image",
label: "Text + Image",
description: "Text beside an image",
icon: <ImagePlay className="h-4 w-4" />,
},
{
type: "video",
label: "Video",
description: "A single video",
icon: <VideoIcon className="h-4 w-4" />,
},
{
type: "text-video",
label: "Text + Video",
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 }) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" className="w-full border-dashed gap-2">
<Plus className="h-4 w-4" />
Add Block
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-56">
<DropdownMenuLabel>Choose a block type</DropdownMenuLabel>
<DropdownMenuSeparator />
{BLOCK_TYPES.map(({ type, label, description, icon }) => (
<DropdownMenuItem
key={type}
onClick={() => onAdd(type)}
className="flex items-start gap-3 py-2 cursor-pointer"
>
<span className="mt-0.5 text-muted-foreground">{icon}</span>
<div className="flex flex-col">
<span className="text-sm font-medium">{label}</span>
<span className="text-xs text-muted-foreground">{description}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,86 @@
import { X } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
import { getTierColor, getContrastText } from "@/utils/tierColors";
import { goToLink } from "@/components/generic/notificationDisplay";
// Admin counterpart to StickyAnnouncementBar (client). Only supports the
// explicit link_url from the composer's "Link" section — the type-based
// fallback resolver in notificationDisplay.jsx points at client-only routes
// (/course/:id, /plans, /group/:id), which don't exist in the admin app.
function resolveClickAction(stickyAnnouncement) {
const linkUrl = stickyAnnouncement.data?.linkUrl || null;
if (!linkUrl) return null;
return {
label: stickyAnnouncement.data?.linkLabel || "Open Link",
go: (navigate) => goToLink(linkUrl, navigate),
};
}
// Up to 2 sticky alerts shown at once, stacked — no dialog on click anymore;
// the centered title text itself is the click target and redirects straight
// to the alert's link (if any).
const MAX_VISIBLE_STICKY = 2;
function AdminStickyAnnouncementRow({ announcement, onDismiss }) {
const navigate = useNavigate();
const swatch = getTierColor(announcement.color || "indigo").swatch;
const textColor = getContrastText(swatch, announcement.color || "indigo");
const clickAction = resolveClickAction(announcement);
return (
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
<div className="relative w-full rounded-none py-3 flex items-center justify-center px-10">
<p
className={["text-sm font-semibold leading-snug truncate", clickAction ? "cursor-pointer" : ""].join(" ")}
style={{ color: textColor }}
onClick={clickAction ? () => clickAction.go(navigate) : undefined}
>
{announcement.title || "Announcement"}
</p>
{/* Only way to dismiss a sticky alert. */}
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
aria-label="Dismiss sticky announcement"
title="Dismiss"
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
style={{ color: textColor }}
>
<X className="size-4" />
</div>
</div>
</div>
);
}
export default function AdminStickyAnnouncementBar() {
const { stickyAnnouncements, markSeen } = useAdminNotifications();
const visible = stickyAnnouncements.slice(0, MAX_VISIBLE_STICKY);
if (visible.length === 0) return null;
return (
<div className="w-full flex flex-col">
{visible.map((announcement) => (
<AdminStickyAnnouncementRow
key={announcement.notification_id}
announcement={announcement}
onDismiss={() => markSeen(announcement.notification_id)}
/>
))}
</div>
);
}
@@ -0,0 +1,10 @@
// components/generic/AssetPageLoader.jsx
import { Loader2 } from "lucide-react";
export default function AssetPageLoader() {
return (
<div className="flex items-center justify-center h-96 rounded-lg border bg-muted/30">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
</div>
);
}
@@ -0,0 +1,409 @@
import { useEffect, useState, useCallback, useRef, memo } from "react";
import { Search, CheckCircle2, SlidersHorizontal, X } from "lucide-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Spinner } from "@/components/ui/spinner";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAssets, useMediaTokens } from "@/contexts/AdminAssetsContext";
import { formatPlayerTime } from "@/utils/format.util";
const STREAM_BASE = `${import.meta.env.VITE_API_URL}/client/media/stream`;
const DEBOUNCE_MS = 400;
const EXT_OPTIONS = {
image: ["svg", "png", "jpg", "jpeg", "webp", "gif"],
video: ["mp4", "mov", "webm", "avi"],
audio: ["mp3", "wav", "ogg", "m4a"],
document: ["pdf", "docx", "xlsx", "pptx"],
};
// ─── Asset Card ───────────────────────────────────────────────────────────────
// streamSrc is resolved at the sheet level (batch token request) — no per-card fetch.
// memo()'d because a sibling AssetPickerSheet's mediaTokens update re-runs
// this component's own function body (context change) even when none of
// THIS card's actual props changed — memo + a stable onSelect (see
// handleSelect's useCallback below) lets it bail out instead of re-rendering
// every card in every mounted-but-untouched picker.
const AssetCard = memo(function AssetCard({ asset, streamSrc, selected, onSelect }) {
const directThumb = asset.thumbnail_url ?? asset.file_url;
const thumb = streamSrc ?? directThumb;
// duration comes straight off the asset row (ffprobe-derived at upload) —
// shows regardless of whether a thumbnail image loaded, since a missing
// preview shouldn't also mean losing the one other useful signal at a glance.
const showDuration = (asset.file_type === "video" || asset.file_type === "audio") && !!asset.duration;
return (
<button
type="button"
onClick={() => onSelect(asset)}
className={[
"relative rounded-lg border-2 overflow-hidden transition-all text-left w-full",
"hover:border-primary/60 hover:shadow-sm",
selected ? "border-primary ring-2 ring-primary/20" : "border-border",
].join(" ")}
>
<div className="relative aspect-video bg-muted w-full overflow-hidden">
{thumb ? (
<img src={thumb} alt={asset.display_name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<span className="text-xs text-muted-foreground">No preview</span>
</div>
)}
{showDuration && (
<span className="absolute bottom-1 right-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-medium text-white tabular-nums">
{formatPlayerTime(asset.duration)}
</span>
)}
</div>
<div className="p-2">
<p className="text-xs font-medium truncate">{asset.display_name}</p>
{asset.extension && (
<p className="text-[10px] text-muted-foreground uppercase mt-0.5">{asset.extension}</p>
)}
</div>
{selected && (
<div className="absolute top-1.5 right-1.5">
<CheckCircle2 className="h-5 w-5 text-primary fill-white" />
</div>
)}
</button>
);
});
function EmptyState({ fileType }) {
return (
<div className="flex flex-col items-center justify-center h-48 gap-2">
<p className="text-sm text-muted-foreground">No {fileType} files found.</p>
</div>
);
}
// ─── Main Sheet ───────────────────────────────────────────────────────────────
// allowedExtensions optionally narrows a fileType's picker to a subset of
// EXT_OPTIONS (e.g. the Document Import block only wants pdf/pptx out of the
// full document set). Omit it and behavior is unchanged from every existing
// caller — the extension filter stays purely an opt-in user-facing toggle.
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect, allowedExtensions }) {
// NOTE: previously this returned null before any hooks ran when `open` was
// false. Since the parent renders this component unconditionally (only the
// `open` prop toggles), that meant React remounted every hook from scratch
// on each open — wiping local state and forcing a full refetch every time,
// plus skipping the <Sheet> close transition. Visibility is controlled by
// <Sheet open={open}> below instead, so state survives across open/close
// toggles. Because this component now stays mounted for its parent's
// lifetime, a page with several media blocks keeps several instances
// mounted at once — assets/pagination/loading are therefore local state
// (below), not AdminAssetsContext state, so opening one picker doesn't
// re-render or stomp the list of every other mounted one. mediaTokens and
// the request-level TTL cache stay in context — they're pure per-asset /
// per-query caches, safe (and worth) sharing across instances.
const { fetchAssetsList } = useAssets();
// Separate context from useAssets() on purpose — see useMediaTokens's
// definition in AdminAssetsContext.jsx. Keeps this picker from
// re-rendering when the admin Assets table's unrelated list state changes.
const { mediaTokens, getMediaTokens } = useMediaTokens();
const [search, setSearch] = useState("");
const [activeExts, setActiveExts] = useState(new Set());
const [page, setPage] = useState(1);
const [selected, setSelected] = useState(null);
const [filterOpen, setFilterOpen] = useState(false);
// Local, per-instance list state — deliberately NOT shared context state.
// A page can mount many AssetPickerSheet instances at once (one per media
// block); sharing a single assets/pagination/loading slice meant opening
// one picker re-rendered and stomped the list of every other mounted one.
const [assets, setAssets] = useState([]);
const [pagination, setPagination] = useState({ page: 1, totalPages: 0, hasPrevPage: false, hasNextPage: false });
const [loading, setLoading] = useState(false);
// Tracks the follow-up batch token fetch separately from `loading` (the
// list fetch) — combined into `showSpinner` below so the grid never
// renders with placeholder "No preview" cards that then pop thumbnails
// in a beat later. One spinner, then everything appears already loaded.
const [tokensLoading, setTokensLoading] = useState(false);
const debounceRef = useRef(null);
const isFirstSearchRun = useRef(true);
const LIMIT = 10;
const extOptions = allowedExtensions ?? EXT_OPTIONS[fileType] ?? [];
const resolveStreamSrc = useCallback((assetId) => {
const entry = mediaTokens[String(assetId)];
if (!entry) return null;
return entry.thumbnail_url ?? `${STREAM_BASE}/${entry.token}`;
}, [mediaTokens]);
// ── Build and fire fetch ──────────────────────────────────────────────────
const doFetch = useCallback((searchVal, extSet, pg) => {
// No chips picked: fall back to allowedExtensions (if the caller
// passed one) as a mandatory whitelist, otherwise no extension
// filter at all — matches every pre-existing caller's behavior.
const extFilterValue = extSet.size > 0 ? [...extSet] : (allowedExtensions ?? null);
const filters = [
...(fileType ? [{ id: "file_type", value: [fileType] }] : []),
...(searchVal.trim() ? [{ id: "display_name", value: [searchVal.trim()] }] : []),
...(extFilterValue?.length ? [{ id: "extension", value: extFilterValue }] : []),
];
setLoading(true);
fetchAssetsList({ page: pg, limit: LIMIT, filters })
.then(({ assets: nextAssets, pagination: nextPagination }) => {
setAssets(nextAssets);
setPagination(nextPagination);
})
.finally(() => setLoading(false));
}, [fileType, fetchAssetsList, allowedExtensions]);
// ── Immediate fetch: on open, or when filters/page change while open ──────
// (fetchAssets itself is TTL-cached in AdminAssetsContext, so reopening
// with the same query within the cache window costs no network round-trip.)
useEffect(() => {
if (!open) return;
doFetch(search, activeExts, page);
}, [open, activeExts, page]);
// ── Debounced fetch: only when the user edits the search box ──────────────
// Deliberately does NOT depend on `open` — otherwise this and the effect
// above both fire on every sheet open, doubling the request.
useEffect(() => {
if (isFirstSearchRun.current) { isFirstSearchRun.current = false; return; }
if (!open) return;
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
doFetch(search, activeExts, 1);
}, DEBOUNCE_MS);
return () => clearTimeout(debounceRef.current);
}, [search]);
// ── Batch token fetch after assets load ───────────────────────────────────
// One request for all S3 assets on the current page instead of N per-card
// requests. getMediaTokens (AdminAssetsContext) already skips any asset_id
// whose cached token is still valid, so reopening the sheet within the
// token's ~30min TTL issues no request at all for previously-seen assets.
useEffect(() => {
if (!open || !assets.length) return;
const s3Ids = assets
.filter((a) => a.storage_provider === "s3")
.map((a) => a.asset_id);
if (!s3Ids.length) return;
setTokensLoading(true);
getMediaTokens(s3Ids)
.catch((err) => {
console.warn("[AssetPickerSheet] batch token fetch failed:", err?.response?.status, err?.message);
})
.finally(() => setTokensLoading(false));
}, [assets, open, getMediaTokens]);
// ── Reset on close ────────────────────────────────────────────────────────
useEffect(() => {
if (!open) {
setSearch("");
setActiveExts(new Set());
setPage(1);
setSelected(null);
setFilterOpen(false);
}
}, [open]);
const toggleExt = (ext) => {
setActiveExts((prev) => {
const next = new Set(prev);
next.has(ext) ? next.delete(ext) : next.add(ext);
return next;
});
setPage(1);
};
const clearFilters = () => {
setActiveExts(new Set());
setPage(1);
};
// useCallback so AssetCard's memo() (above) actually has a stable prop to
// compare against — an inline function here would give every card a new
// onSelect reference on every render, defeating the memo entirely.
const handleSelect = useCallback((asset) => {
setSelected(asset.asset_id);
// Pass the resolved stream/presigned URL as a second arg so callers
// (e.g. badge image picker) can use the authenticated URL directly
// rather than falling back to asset.file_url which is a private CDN
// key that the browser cannot load without S3 credentials.
const resolvedUrl = resolveStreamSrc(asset.asset_id)
?? asset.thumbnail_url
?? asset.file_url
?? null;
onSelect(asset, resolvedUrl);
onOpenChange(false);
}, [resolveStreamSrc, onSelect, onOpenChange]);
const label = fileType
? `${fileType.charAt(0).toUpperCase()}${fileType.slice(1)}s`
: "Files";
const hasActiveFilters = activeExts.size > 0;
const showSpinner = loading || tokensLoading;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
{/* ── Header ── */}
<SheetHeader className="px-6 pt-6 pb-4 border-b">
<SheetTitle>Select {label}</SheetTitle>
<SheetDescription>Click a file to attach it.</SheetDescription>
</SheetHeader>
{/* ── Search + Filter ── */}
<div className="px-6 py-3 border-b space-y-2">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={`Search ${label.toLowerCase()}…`}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
{extOptions.length > 0 && (
<Popover open={filterOpen} onOpenChange={setFilterOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant={hasActiveFilters ? "default" : "outline"}
size="icon"
className="relative shrink-0"
>
<SlidersHorizontal className="h-4 w-4" />
{hasActiveFilters && (
<span className="absolute -top-1.5 -right-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground font-medium">
{activeExts.size}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-3 space-y-1">
<div className="flex items-center justify-between">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">File type</p>
{hasActiveFilters && (
<button
type="button"
onClick={clearFilters}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
)}
</div>
<div className="flex flex-wrap gap-1.5">
{extOptions.map((ext) => (
<button
key={ext}
type="button"
onClick={() => toggleExt(ext)}
className={[
"px-2.5 py-1 rounded-full border text-xs uppercase font-mono transition-colors",
activeExts.has(ext)
? "bg-primary text-primary-foreground border-primary"
: "bg-card border-border hover:bg-muted",
].join(" ")}
>
{ext}
</button>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
{hasActiveFilters && (
<div className="flex flex-wrap gap-1.5">
{[...activeExts].map((ext) => (
<Badge key={ext} variant="secondary" className="gap-1 pr-1 uppercase text-[10px] font-mono">
{ext}
<button type="button" onClick={() => toggleExt(ext)} className="ml-0.5 hover:opacity-70">
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* ── Grid ── */}
{/* showSpinner covers both the list fetch AND the follow-up batch
token fetch, so the grid only ever appears once every card
already has its thumbnail resolved — no placeholder-then-pop-in
flicker. Spinner fills the full remaining sheet height (not a
small fixed box) so it's centered in the whole visible area. */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{showSpinner ? (
<div className="flex items-center justify-center h-full min-h-[24rem]">
<Spinner className="h-6 w-6" />
</div>
) : !assets.length ? (
<EmptyState fileType={fileType ?? "asset"} />
) : (
<div className="grid grid-cols-2 gap-3">
{assets.map((asset) => (
<AssetCard
key={asset.asset_id}
asset={asset}
streamSrc={resolveStreamSrc(asset.asset_id)}
selected={selected === asset.asset_id}
onSelect={handleSelect}
/>
))}
</div>
)}
</div>
{/* ── Pagination ── */}
{/* Always shown once there's at least one page of results (even a
single page) — Prev/Next self-disable via hasPrevPage/hasNextPage,
so a one-page list just reads "Page 1 of 1" with both disabled
rather than hiding the control entirely. */}
{pagination.totalPages > 0 && (
<div className="flex items-center justify-between px-6 py-3 border-t text-sm">
<span className="text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<div className="flex gap-2">
<button
type="button"
disabled={!pagination.hasPrevPage || showSpinner}
onClick={() => setPage((p) => p - 1)}
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
>
Prev
</button>
<button
type="button"
disabled={!pagination.hasNextPage || showSpinner}
onClick={() => setPage((p) => p + 1)}
className="px-3 py-1 rounded border text-xs disabled:opacity-40 hover:bg-muted transition-colors"
>
Next
</button>
</div>
</div>
)}
</SheetContent>
</Sheet>
);
}
@@ -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 = 200) {
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 &middot; 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 &middot; 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>
)
}
@@ -0,0 +1,96 @@
// components/generic/CMS/BlockList.jsx
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 ───────────────────────────────────────────────────────────
//
// blockId is forwarded to TextBlock (and any future rich-text blocks) so the
// WYSIWYG editor knows exactly when to seed its innerHTML from saved data.
function BlockContent({ block, onUpdate }) {
const { id, type, content } = block;
switch (type) {
case "text":
return (
<TextBlock
blockId={id}
content={content}
onUpdate={onUpdate}
/>
);
case "image":
return <ImageBlock content={content} onUpdate={onUpdate} />;
case "text-image":
return <TextImageBlock blockId={id} content={content} onUpdate={onUpdate} />;
case "video":
return <VideoBlock content={content} onUpdate={onUpdate} />;
case "text-video":
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>;
}
}
// ─── Default content per type ─────────────────────────────────────────────────
export const DEFAULT_CONTENT = {
"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: "", duration_seconds: 0 },
"text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right", duration_seconds: 0 },
"audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "", duration_seconds: 0 },
"code": { language: "javascript", code: "" },
"markdown": { body: "" },
};
// ─── List ─────────────────────────────────────────────────────────────────────
export function BlockList({ blocks, onUpdate, onMove, onDelete }) {
if (!blocks.length) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/20 py-16 gap-2">
<p className="text-sm text-muted-foreground">No blocks yet.</p>
<p className="text-xs text-muted-foreground">
Use the button below to add your first block.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-3">
{blocks.map((block, index) => (
<BlockWrapper
key={block.id}
type={block.type}
index={index}
total={blocks.length}
onMoveUp={() => onMove(block.id, "up")}
onMoveDown={() => onMove(block.id, "down")}
onDelete={() => onDelete(block.id)}
>
<BlockContent
block={block}
onUpdate={(updated) => onUpdate(block.id, updated)}
/>
</BlockWrapper>
))}
</div>
);
}
@@ -0,0 +1,80 @@
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",
"audio": "Audio",
};
export function BlockWrapper({
type,
index,
total,
onMoveUp,
onMoveDown,
onDelete,
children,
}) {
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>
</div>
);
}
@@ -0,0 +1,404 @@
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";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { Spinner } from "@/components/ui/spinner";
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
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×
// True from the moment `src` is set until the browser has actually
// buffered enough to play (or stalls mid-playback) — same gap VideoBlock
// closes, so a slow-loading audio file doesn't just sit there silently.
const [mediaLoading, setMediaLoading] = useState(true);
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]);
// Reset the loading spinner whenever the src actually changes (new
// asset picked, or the S3 token above just resolved).
useEffect(() => { setMediaLoading(true); }, [src]);
// ── 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);
}
}, []);
const onLoadedData = useCallback(() => setMediaLoading(false), []);
const onCanPlay = useCallback(() => setMediaLoading(false), []);
const onWaiting = useCallback(() => setMediaLoading(true), []);
const onPlaying = useCallback(() => setMediaLoading(false), []);
// ── 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"
// duration_seconds — probed media length, read by duration.util.js on save
//
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() ?? "",
duration_seconds: Number(asset.duration) || 0,
});
setPlaying(false);
setCurrentTime(0);
setDuration(0);
setBuffered(0);
setStreamSrc(null);
setStreamThumb(null);
};
// ─────────────────────────────────────────────────────────────────────────
return (
<div className="space-y-3">
{!readOnly && <Label>Audio</Label>}
{isS3 && tokenLoading ? (
<MediaFallback className="w-full h-28 rounded-xl border border-border" />
) : src ? (
<div className="w-full 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}
onLoadedData={onLoadedData}
onCanPlay={onCanPlay}
onWaiting={onWaiting}
onPlaying={onPlaying}
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-primary" />
)}
<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 flex items-center justify-center">
{mediaLoading ? (
<Spinner className="size-6 text-white" />
) : thumbnail ? (
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
) : (
<Music2 className="w-7 h-7 text-white/30" />
)}
</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 accent-primary cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer
[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer"
/>
</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>
);
}
@@ -0,0 +1,86 @@
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 { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
function MediaPlaceholder({ onClick }) {
return (
<button
type="button"
onClick={onClick}
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"
>
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">
Click to select an image
</p>
</button>
);
}
export function ImageBlock({ content, onUpdate, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
// content.url is redacted (null) server-side for S3 assets — see
// redactS3Url() in controllers/admin/assets.controller.js. Re-resolve
// through media.util.js instead of trusting the persisted url/thumbnail.
const { src, loading } = useAssetPreviewSrc(
{ asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url, thumbnail_url: content?.url },
{ scope: "admin" },
);
return (
<div className="space-y-3">
<Label>Image</Label>
{loading ? (
<MediaFallback className="w-full aspect-video rounded-lg" />
) : src ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer group"
onClick={() => setPickerOpen(true)}
>
<img
src={src}
alt={content.alt ?? ""}
className="w-full aspect-video object-cover"
/>
<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 Image</p>
</div>
</div>
) : (
<MediaPlaceholder onClick={() => setPickerOpen(true)} />
)}
{content.asset_id && (
<div className="space-y-1.5">
<Label>Alt Text</Label>
<Input
placeholder="Describe the image..."
value={content.alt ?? ""}
onChange={(e) => onUpdate({ ...content, alt: e.target.value })}
/>
</div>
)}
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
storage_provider: asset.storage_provider ?? null,
url: asset.file_url,
})}
/>
)}
</div>
);
}
@@ -0,0 +1,157 @@
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";
// ─── 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">
{body.trim() ? (
<div className="typeset 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>
);
}
@@ -0,0 +1,425 @@
// components/generic/CMS/Blocks/TextBlock.jsx
import { useEffect, useRef, useState } from "react";
import {
Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, AlignJustify,
List, ListOrdered, Indent, Outdent,
Link2, FileText, Table, Code, Code2,
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../../AssetPickerSheet";
import { cn } from "@/lib/utils";
// ─── Toolbar button ───────────────────────────────────────────────────────────
function ToolbarBtn({ title, onMouseDown, active, children }) {
return (
<button
type="button"
title={title}
onMouseDown={onMouseDown}
className={cn(
"h-7 w-7 flex items-center justify-center rounded text-muted-foreground",
"hover:bg-accent hover:text-accent-foreground transition-colors shrink-0",
active && "bg-accent text-accent-foreground"
)}
>
{children}
</button>
);
}
// ─── Toolbar divider ──────────────────────────────────────────────────────────
function Divider() {
return <span className="w-px h-4 bg-border mx-0.5 shrink-0" />;
}
// ─── Format options ───────────────────────────────────────────────────────────
const FORMAT_OPTIONS = [
{ label: "Paragraph", val: "p" },
{ label: "Heading 1", val: "h1" },
{ label: "Heading 2", val: "h2" },
{ label: "Heading 3", val: "h3" },
];
// ─── Table picker popover ─────────────────────────────────────────────────────
// A small grid that lets the user hover to choose rows × columns (up to 8×8),
// then click to confirm. Rendered inline in the toolbar.
const MAX_ROWS = 8;
const MAX_COLS = 8;
function TablePicker({ onInsert, onClose }) {
const [hovered, setHovered] = useState({ r: 0, c: 0 });
return (
// Overlay — clicking outside closes the picker without inserting
<div className="fixed inset-0 z-50" onMouseDown={onClose}>
<div
className="absolute z-50 bg-popover border rounded-lg shadow-lg p-3 flex flex-col gap-2"
style={{ top: "var(--table-picker-y, 40px)", left: "var(--table-picker-x, 0px)" }}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Label */}
<p className="text-xs text-muted-foreground select-none">
{hovered.r > 0 && hovered.c > 0
? `${hovered.r} × ${hovered.c} table`
: "Hover to select size"}
</p>
{/* Grid */}
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: `repeat(${MAX_COLS}, 1.25rem)` }}
>
{Array.from({ length: MAX_ROWS }, (_, r) =>
Array.from({ length: MAX_COLS }, (_, c) => (
<div
key={`${r}-${c}`}
onMouseEnter={() => setHovered({ r: r + 1, c: c + 1 })}
onClick={() => {
onInsert(hovered.r, hovered.c);
onClose();
}}
className={cn(
"h-5 w-5 rounded-sm border cursor-pointer transition-colors",
r < hovered.r && c < hovered.c
? "bg-primary/20 border-primary/50"
: "bg-muted border-border"
)}
/>
))
)}
</div>
</div>
</div>
);
}
// ─── RichTextEditor ───────────────────────────────────────────────────────────
export function RichTextEditor({ blockId, value, onChange, readOnly = false }) {
const editorRef = useRef(null);
const initializedFor = useRef(null);
const savedRange = useRef(null);
const tableButtonRef = useRef(null);
const [docPickerOpen, setDocPickerOpen] = useState(false);
const [tablePickerOpen, setTablePickerOpen] = useState(false);
// ── Seed innerHTML exactly once per blockId ────────────────────────────────
useEffect(() => {
if (!editorRef.current) return;
if (initializedFor.current === blockId) return;
editorRef.current.innerHTML = value ?? "";
initializedFor.current = blockId;
}, [blockId, value]);
// ── Position the table picker below its toolbar button ────────────────────
useEffect(() => {
if (!tablePickerOpen || !tableButtonRef.current) return;
const rect = tableButtonRef.current.getBoundingClientRect();
document.documentElement.style.setProperty("--table-picker-y", `${rect.bottom + 6}px`);
document.documentElement.style.setProperty("--table-picker-x", `${rect.left}px`);
}, [tablePickerOpen]);
// ── execCommand helpers ────────────────────────────────────────────────────
const exec = (cmd, val = null) => {
editorRef.current?.focus();
document.execCommand(cmd, false, val);
};
const applyFormat = (val) => {
editorRef.current?.focus();
document.execCommand("formatBlock", false, `<${val}>`);
};
const isActive = (cmd) => {
try { return document.queryCommandState(cmd); } catch { return false; }
};
// ── URL link ──────────────────────────────────────────────────────────────
const insertUrlLink = () => {
const url = window.prompt("Enter URL:", "https://");
if (url) exec("createLink", url);
};
// ── Document link ─────────────────────────────────────────────────────────
const openDocPicker = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
savedRange.current = sel.getRangeAt(0).cloneRange();
}
setDocPickerOpen(true);
};
const handleDocSelect = (asset) => {
setDocPickerOpen(false);
const url = asset.file_url;
const label = asset.display_name ?? "Document";
editorRef.current?.focus();
const sel = window.getSelection();
if (savedRange.current && sel) {
sel.removeAllRanges();
sel.addRange(savedRange.current);
}
const html = `<a href="${url}" class="doc-link" target="_blank" rel="noopener noreferrer">${label}</a>`;
document.execCommand("insertHTML", false, html);
onChange(editorRef.current.innerHTML);
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).
// A paragraph after the table lets the user continue typing below it.
const insertTable = (rows, cols) => {
if (rows < 1 || cols < 1) return;
// Save cursor before opening picker collapses it
editorRef.current?.focus();
const sel = window.getSelection();
if (savedRange.current && sel) {
sel.removeAllRanges();
sel.addRange(savedRange.current);
}
// Build header row
const headerCells = Array.from({ length: cols })
.map((_, i) => `<th>Column ${i + 1}</th>`)
.join("");
// Build body rows
const bodyRows = Array.from({ length: rows - 1 })
.map(() =>
`<tr>${Array.from({ length: cols }).map(() => "<td><br></td>").join("")}</tr>`
)
.join("");
const tableHTML = `
<table>
<thead><tr>${headerCells}</tr></thead>
<tbody>${bodyRows}</tbody>
</table>
<p><br></p>
`;
document.execCommand("insertHTML", false, tableHTML);
onChange(editorRef.current.innerHTML);
savedRange.current = null;
};
const openTablePicker = () => {
// Save current cursor so we can restore it after the picker closes
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
savedRange.current = sel.getRangeAt(0).cloneRange();
}
setTablePickerOpen(true);
};
// ── Toolbar groups ─────────────────────────────────────────────────────────
const GROUPS = [
[
{ 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: "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" },
],
];
return (
<>
<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">
{/* Block format select */}
<select
className="h-7 text-xs border rounded px-1 mr-0.5 bg-background cursor-pointer shrink-0"
defaultValue="p"
onChange={(e) => applyFormat(e.target.value)}
>
{FORMAT_OPTIONS.map((o) => (
<option key={o.val} value={o.val}>{o.label}</option>
))}
</select>
<Divider />
{/* Formatting groups */}
{GROUPS.map((group, gi) => (
<div key={gi} className="flex items-center gap-0.5">
{group.map(({ cmd, Icon, title }) => (
<ToolbarBtn
key={cmd}
title={title}
active={isActive(cmd)}
onMouseDown={(e) => { e.preventDefault(); exec(cmd); }}
>
<Icon className="h-3.5 w-3.5" />
</ToolbarBtn>
))}
<Divider />
</div>
))}
{/* URL link */}
<ToolbarBtn
title="Insert URL link"
onMouseDown={(e) => { e.preventDefault(); insertUrlLink(); }}
>
<Link2 className="h-3.5 w-3.5" />
</ToolbarBtn>
{/* Document link */}
<ToolbarBtn
title="Embed document link"
onMouseDown={(e) => { e.preventDefault(); openDocPicker(); }}
>
<FileText className="h-3.5 w-3.5" />
</ToolbarBtn>
<Divider />
{/* Insert table */}
<ToolbarBtn
title="Insert table"
active={tablePickerOpen}
onMouseDown={(e) => {
e.preventDefault();
tablePickerOpen ? setTablePickerOpen(false) : openTablePicker();
}}
>
<span ref={tableButtonRef} className="flex items-center justify-center">
<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 ── */}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
onInput={() => onChange(editorRef.current.innerHTML)}
className="typeset min-h-[120px] px-3 m-0 py-0 text-sm focus:outline-none"
/>
</div>
{/* Table size picker */}
{tablePickerOpen && (
<TablePicker
onInsert={insertTable}
onClose={() => setTablePickerOpen(false)}
/>
)}
{/* Document asset picker */}
{!readOnly && (
<AssetPickerSheet
open={docPickerOpen}
onOpenChange={setDocPickerOpen}
fileType="document"
onSelect={handleDocSelect}
/>
)}
</>
);
}
// ─── TextBlock ────────────────────────────────────────────────────────────────
export function TextBlock({ content, onUpdate, blockId, readOnly = false }) {
if (readOnly) {
return (
<div
className="typeset 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>
);
}
@@ -0,0 +1,121 @@
// components/generic/CMS/Blocks/TextImageBlock.jsx
import { useState } from "react";
import { ImageIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
import { AssetPickerSheet } from "../../AssetPickerSheet";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
export function TextImageBlock({ content, onUpdate, blockId, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
// content.url is redacted (null) server-side for S3 assets — re-resolve
// through media.util.js instead of trusting the persisted url/thumbnail.
const { src, loading } = useAssetPreviewSrc(
{ asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url, thumbnail_url: content?.url },
{ scope: "admin" },
);
return (
<div className="space-y-4">
{/* ── Image position ── */}
<div className="space-y-1.5 max-w-[200px]">
<Label>Image Position</Label>
<Select
value={content.image_position ?? "right"}
onValueChange={(v) => onUpdate({ ...content, image_position: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* ── Text side — WYSIWYG ── */}
<div className="space-y-1.5">
<Label>Content</Label>
<RichTextEditor
blockId={`${blockId}_text`}
value={content.body ?? ""}
onChange={(html) => onUpdate({ ...content, body: html })}
/>
</div>
{/* ── Image side ── */}
<div className={[
"space-y-1.5",
content.image_position === "left" ? "md:order-first" : "",
].join(" ")}>
<Label>Image</Label>
{loading ? (
<MediaFallback className="w-full aspect-video rounded-lg" />
) : src ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer group"
onClick={() => setPickerOpen(true)}
>
<img
src={src}
alt={content.alt ?? ""}
className="w-full aspect-video object-cover"
/>
<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 Image</p>
</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"
>
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Click to select an image</p>
</button>
)}
{content.asset_id && (
<div className="space-y-1.5">
<Label>Alt Text</Label>
<Input
placeholder="Describe the image..."
value={content.alt ?? ""}
onChange={(e) => onUpdate({ ...content, alt: e.target.value })}
/>
</div>
)}
</div>
</div>
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
storage_provider: asset.storage_provider ?? null,
url: asset.file_url,
})}
/>
)}
</div>
);
}
@@ -0,0 +1,124 @@
// components/generic/CMS/Blocks/TextVideoBlock.jsx
import { useState } from "react";
import { VideoIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
import { AssetPickerSheet } from "../../AssetPickerSheet";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
export function TextVideoBlock({ content, onUpdate, blockId, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
// content.thumbnail_url is a presigned S3 URL captured at pick time for
// S3 assets — it expires. Re-resolve through media.util.js instead of
// trusting the persisted value, mirroring VideoBlock.jsx.
const { thumbnailUrl, loading } = useAssetPreviewSrc(
{ asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url, thumbnail_url: content?.thumbnail_url },
{ scope: "admin" },
);
const thumb = thumbnailUrl ?? content.thumbnail_url ?? null;
return (
<div className="space-y-4">
{/* ── Video position ── */}
<div className="space-y-1.5 max-w-[200px]">
<Label>Video Position</Label>
<Select
value={content.video_position ?? "right"}
onValueChange={(v) => onUpdate({ ...content, video_position: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="left">Left</SelectItem>
<SelectItem value="right">Right</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* ── Text side — WYSIWYG ── */}
<div className="space-y-1.5">
<Label>Content</Label>
<RichTextEditor
blockId={`${blockId}_text`}
value={content.body ?? ""}
onChange={(html) => onUpdate({ ...content, body: html })}
/>
</div>
{/* ── Video side ── */}
<div className={[
"space-y-1.5",
content.video_position === "left" ? "md:order-first" : "",
].join(" ")}>
<Label>Video</Label>
{loading ? (
<MediaFallback className="w-full aspect-video rounded-lg" />
) : content.asset_id ? (
<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>
<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>
)}
</div>
</div>
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="video"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
storage_provider: asset.storage_provider ?? null,
url: asset.file_url,
thumbnail_url: asset.thumbnail_url ?? null,
duration_seconds: Number(asset.duration) || 0,
})}
/>
)}
</div>
);
}
@@ -0,0 +1,502 @@
import { useRef, useState, useEffect, useCallback } from "react";
import {
Play,
Pause,
SkipBack,
Volume2,
VolumeX,
Maximize2,
Minimize2,
VideoIcon,
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../../AssetPickerSheet";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { Spinner } from "@/components/ui/spinner";
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
export function VideoBlock({ content, onUpdate, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
// content.url is redacted (null) server-side for S3 assets — see
// redactS3Url() in controllers/admin/assets.controller.js. Re-resolve
// through media.util.js instead of trusting the persisted url/thumbnail.
//
// thumbnail_url is intentionally NOT passed here: resolveAssetSrc()'s fast
// path falls back to thumbnail_url when there's no stream_token, which for
// a video asset with a redacted (null) file_url meant `src` resolved to
// the poster JPEG instead of the video file, and the browser then refused
// to play it ("media resource ... was not suitable"). Omitting it forces
// the async mint-token path, which resolves the real video stream src.
const { src, loading } = useAssetPreviewSrc(
{ asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url },
{ scope: "admin" },
);
const poster = content?.thumbnail_url ?? undefined;
const vidRef = useRef(null);
const wrapRef = 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);
// Native Fullscreen API works on desktop (all browsers) and Android Chrome.
// iOS Safari doesn't support Fullscreen API on arbitrary elements at all, so
// pseudoFullscreen is a CSS-only (fixed inset-0) fallback that keeps our
// custom controls instead of falling back to native <video> fullscreen.
const [nativeFullscreen, setNativeFullscreen] = useState(false);
const [pseudoFullscreen, setPseudoFullscreen] = useState(false);
const isFullscreen = nativeFullscreen || pseudoFullscreen;
// True from the moment `src` is set until the browser has actually
// buffered enough to render a frame (or stalls mid-playback) — closes the
// gap between "token resolved" (the `loading` from useAssetPreviewSrc
// above) and "video is actually watchable", which used to render as a
// blank black box with no indication anything was happening, especially
// on large/slow-loading files.
const [mediaLoading, setMediaLoading] = useState(true);
const [hoverProgress, setHoverProgress] = useState(null); // { x, time }
const previewVidRef = useRef(null);
// Fullscreen-only auto-hide for the controls bar, mirroring the client
// player: idle 3s while playing fades the bar out, any mouse movement (or
// a pause) brings it back. Outside fullscreen the bar stays put — it's a
// normal in-flow row there, not an overlay, so hiding it would just leave
// a blank gap.
const [controlsVisible, setControlsVisible] = useState(true);
const hideTimer = useRef(null);
// Reset player when video changes
useEffect(() => {
setPlaying(false);
setProgress(0);
setCurrentTime(0);
setTotalDuration(0);
setOverlayVisible(true);
setMediaLoading(true);
}, [src]);
// ── 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); };
const onLoadedData = () => setMediaLoading(false);
const onCanPlay = () => setMediaLoading(false);
const onWaiting = () => setMediaLoading(true);
const onPlaying = () => setMediaLoading(false);
v.addEventListener("timeupdate", onTimeUpdate);
v.addEventListener("loadedmetadata", onLoaded);
v.addEventListener("ended", onEnded);
v.addEventListener("loadeddata", onLoadedData);
v.addEventListener("canplay", onCanPlay);
v.addEventListener("waiting", onWaiting);
v.addEventListener("playing", onPlaying);
return () => {
v.removeEventListener("timeupdate", onTimeUpdate);
v.removeEventListener("loadedmetadata", onLoaded);
v.removeEventListener("ended", onEnded);
v.removeEventListener("loadeddata", onLoadedData);
v.removeEventListener("canplay", onCanPlay);
v.removeEventListener("waiting", onWaiting);
v.removeEventListener("playing", onPlaying);
};
}, [src]);
// ── Controls ──────────────────────────────────────────────────────────────
const resetHideTimer = useCallback(() => {
setControlsVisible(true);
clearTimeout(hideTimer.current);
if (playing && isFullscreen) {
hideTimer.current = setTimeout(() => setControlsVisible(false), 3000);
}
}, [playing, isFullscreen]);
useEffect(() => {
resetHideTimer();
return () => clearTimeout(hideTimer.current);
}, [playing, isFullscreen, resetHideTimer]);
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); }
}, []);
// Safari doesn't focus a <div tabIndex> on click, which silently kills
// keyboard shortcuts afterwards — force it explicitly.
const handleAreaClick = useCallback(() => {
wrapRef.current?.focus({ preventScroll: true });
togglePlay();
resetHideTimer();
}, [togglePlay, resetHideTimer]);
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 = () => {
// Uses wrapRef instead of getElementById so this works correctly even if
// an admin page ever renders more than one VideoBlock at once (an id
// lookup would always grab the first match, toggling the wrong player).
const el = wrapRef.current;
if (!el) return;
if (isFullscreen) {
if (document.fullscreenElement) document.exitFullscreen();
else if (document.webkitFullscreenElement) document.webkitExitFullscreen();
else setPseudoFullscreen(false);
} else if (el.requestFullscreen) {
el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
// Desktop Safari — supports element fullscreen via the prefixed API.
el.webkitRequestFullscreen();
} else {
// iOS Safari has no element-level Fullscreen API at all.
setPseudoFullscreen(true);
}
};
// Ignore keystrokes aimed at the seek/volume range inputs so arrow keys
// there keep their native behavior instead of double-seeking.
const handleKeyDown = useCallback((e) => {
if (e.target.tagName === "INPUT") return;
const v = vidRef.current;
// Fold single-char keys to lowercase so Shift/Caps-Lock doesn't break
// letter shortcuts (e.g. Shift+F reporting as "F", not "f").
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
switch (key) {
case " ":
e.preventDefault();
togglePlay();
break;
case "ArrowRight":
e.preventDefault();
if (v && v.duration) v.currentTime = Math.min(v.currentTime + 5, v.duration);
break;
case "ArrowLeft":
e.preventDefault();
if (v) v.currentTime = Math.max(v.currentTime - 5, 0);
break;
case "m":
e.preventDefault();
toggleMute();
break;
case "f":
e.preventDefault();
toggleFullscreen();
break;
case "Escape":
if (isFullscreen) {
e.preventDefault();
toggleFullscreen();
}
break;
default: break;
}
}, [togglePlay, toggleMute, toggleFullscreen, isFullscreen]);
// #video-block-wrap now wraps both the video area and the controls bar
// below it (previously just the video), so fullscreen no longer drops the
// seek bar / volume / fullscreen button. isFullscreen also relaxes the
// 16/9 + max-height constraints so the video fills the screen properly.
useEffect(() => {
// Safari (incl. desktop) still fires the prefixed webkitfullscreenchange
// event for element-level fullscreen, so both are listened for.
const onChange = () => {
const active = !!(document.fullscreenElement || document.webkitFullscreenElement);
setNativeFullscreen(active);
// Safari doesn't focus a <div tabIndex> on click, so keyboard
// shortcuts silently stop working after entering fullscreen unless we
// explicitly refocus the wrapper here.
if (active) wrapRef.current?.focus({ preventScroll: true });
};
document.addEventListener("fullscreenchange", onChange);
document.addEventListener("webkitfullscreenchange", onChange);
return () => {
document.removeEventListener("fullscreenchange", onChange);
document.removeEventListener("webkitfullscreenchange", onChange);
};
}, []);
// CSS pseudo-fullscreen fallback (iOS Safari) — lock page scroll and
// reclaim keyboard focus while it's active, restore on exit/unmount.
useEffect(() => {
if (!pseudoFullscreen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
wrapRef.current?.focus({ preventScroll: true });
return () => { document.body.style.overflow = prevOverflow; };
}, [pseudoFullscreen]);
// ── 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"
// duration_seconds — probed media length, read by duration.util.js on save
//
const handleSelect = (asset) => {
onUpdate({
asset_id: asset.asset_id,
storage_provider: asset.storage_provider ?? null,
url: asset.file_url,
thumbnail_url: asset.thumbnail_url ?? null,
title: asset.display_name,
tag: asset.extension?.toUpperCase() ?? "",
duration_seconds: Number(asset.duration) || 0,
});
setPlaying(false);
setProgress(0);
setCurrentTime(0);
setTotalDuration(0);
setOverlayVisible(true);
};
// ─────────────────────────────────────────────────────────────────────────
return (
<div className="space-y-3">
{!readOnly && <Label>Video</Label>}
{loading ? (
<MediaFallback className="w-full aspect-video rounded-lg" />
) : src ? (
<div
id="video-block-wrap"
ref={wrapRef}
tabIndex={0}
onKeyDown={handleKeyDown}
onMouseMove={isFullscreen ? resetHideTimer : undefined}
className={`overflow-hidden bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring ${isFullscreen ? "fixed inset-0 z-[100] flex flex-col" : "rounded-lg border"}`}
>
{/* ── Video area ── */}
<div
className={`relative w-full bg-black cursor-pointer group ${isFullscreen ? "flex-1 min-h-0" : ""}`}
style={isFullscreen ? undefined : { aspectRatio: "16/9", maxHeight: "calc(100vh - 260px)" }}
onClick={handleAreaClick}
>
<video
ref={vidRef}
src={src}
poster={poster}
preload="metadata"
playsInline
className="w-full h-full object-cover"
/>
{/* Loading spinner — covers the gap between src resolving and the
browser actually having a frame to show */}
{mediaLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 pointer-events-none">
<Spinner className="size-8 text-white" />
</div>
)}
{/* Play/pause overlay */}
{!mediaLoading && (
<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 ──
Fullscreen: absolutely positioned over the bottom of the video
(wrap is `fixed inset-0` there, so it's the containing block)
and fades per controlsVisible. Non-fullscreen: normal in-flow
row below the video, always visible. */}
<div className={
isFullscreen
? `absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pt-8 pb-4 flex flex-col gap-2 transition-opacity duration-300 ${controlsVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`
: "px-3 pt-2.5 pb-3 flex flex-col gap-2"
}>
{/* Progress bar */}
<div
className={`relative h-1 rounded-full cursor-pointer ${isFullscreen ? "bg-white/30" : "bg-border"}`}
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, time });
if (previewVidRef.current && isFinite(time) && time >= 0) {
previewVidRef.current.currentTime = time;
}
}}
onMouseLeave={() => setHoverProgress(null)}
>
<div
className={`h-full rounded-full transition-[width] duration-100 ${isFullscreen ? "bg-white" : "bg-foreground"}`}
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"
/>
{/* Scrub preview — hidden video seeked to hover position, no canvas/sprite needed */}
{hoverProgress && (
<div
className="absolute bottom-3 flex 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 bg-black" style={{ width: 160, height: 90 }}>
<video
ref={previewVidRef}
src={src}
preload="auto"
muted
playsInline
disablePictureInPicture
disableRemotePlayback
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 bg-black/70 px-1.5 py-0.5 rounded">
{fmtTime(hoverProgress.time)}
</span>
</div>
)}
</div>
{/* Button row */}
<div className="flex items-center gap-2">
<button onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} className={`transition-colors ${isFullscreen ? "text-white/80 hover:text-white" : "text-muted-foreground hover:text-foreground"}`}>
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
</button>
<button onClick={restart} aria-label="Restart" className={`transition-colors ${isFullscreen ? "text-white/80 hover:text-white" : "text-muted-foreground hover:text-foreground"}`}>
<SkipBack className="size-4" />
</button>
<span className={`text-xs tabular-nums ${isFullscreen ? "text-white/70" : "text-muted-foreground"}`}>
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
</span>
<div className="flex items-center gap-1.5 ml-auto">
<button onClick={toggleMute} aria-label="Toggle mute" className={`transition-colors ${isFullscreen ? "text-white/80 hover:text-white" : "text-muted-foreground hover:text-foreground"}`}>
{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 ${isFullscreen ? "accent-white" : "accent-foreground"}`}
/>
</div>
<button onClick={toggleFullscreen} aria-label={isFullscreen ? "Exit fullscreen" : "Fullscreen"} className={`transition-colors ml-1 ${isFullscreen ? "text-white/80 hover:text-white" : "text-muted-foreground hover:text-foreground"}`}>
{isFullscreen ? <Minimize2 className="size-4" /> : <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,224 @@
// components/blocks/Banner.jsx
import { useEffect, useState } from "react";
import { Megaphone } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel";
import { Progress } from "@/components/ui/progress";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { resolveAssetSrc } from "@/utils/media.util";
// An ad only ever has an internally-authored landing page when it has no
// redirect_link (see applyAdvertisementFields on the backend, which clears
// whichever one the admin didn't choose) — so its presence alone is enough
// to tell whether there's Page Builder content worth surfacing.
function hasLandingPage(ad) {
const page = ad?.landing_page;
if (!page) return false;
return Boolean(page.title || page.description || page.body || page.links?.length);
}
// ── Banner ───────────────────────────────────────────────────────────────────
/**
* Banner advertisement carousel — same carousel shell as Hero, so a placement
* with several live ads (e.g. tier_plans.banner) rotates through all of them
* instead of only ever showing the single highest-priority one.
* Every current-shape ad (content_mode "content") always shows its image with
* badge/headline/description layered on top, same as Hero — the whole slide
* is clickable through to the ad's redirect_link/landing page whenever it has
* no CTA buttons of its own. content_mode "image" is legacy-only (pre-dates
* the mandatory badge/headline/description/image/link shape) and keeps
* rendering as a bare clickable image with no text overlay.
*
* Props:
* ads — array of advertisement objects { content_mode, badge_labels, headline, description, ctas, redirect_link, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-slide click.
*/
export function Banner({ ads, onCtaClick }) {
const [api, setApi] = useState();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
const [viewAd, setViewAd] = useState(null); // ad whose Page Builder content is open in the Dialog
useEffect(() => {
if (!api) return;
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on("select", () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
if (!ads?.length) return null;
const progressValue = count ? (current / count) * 100 : 0;
return (
<div className="w-full">
<Carousel setApi={setApi} className="w-full">
<CarouselContent>
{ads.map((ad) => {
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
// Whole-slide click-through only makes sense when there's no CTA
// row of its own to carry that responsibility instead — a landing
// page (Page Builder content) always wins over the plain redirect.
const clickable = ctas.length === 0 && (hasLandingPage(ad) || ad.redirect_link);
const handleSlideClick = () => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, undefined));
return (
<CarouselItem key={ad.advertisement_id}>
{ad.content_mode !== "content" ? (
// ── Full Image (legacy Image Only ads with no badge/headline/description) ──
<button
type="button"
onClick={() => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, ctas[0]))}
className="relative w-full rounded-xl overflow-hidden bg-muted flex items-center justify-center text-left h-[clamp(180px,22vw,320px)] border"
>
{imageSrc ? (
<img
src={imageSrc}
alt={ad.headline || "Advertisement"}
className="w-full h-full object-cover pointer-events-none select-none"
/>
) : (
<Megaphone className="size-6 text-muted-foreground pointer-events-none select-none" />
)}
</button>
) : (
// ── Image + Content overlay ──
<div
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? handleSlideClick : undefined}
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") handleSlideClick(); } : undefined}
className={"relative w-full rounded-xl overflow-hidden border text-left h-[clamp(180px,22vw,320px)]" + (clickable ? " cursor-pointer" : "")}
>
{imageSrc ? (
<img
src={imageSrc}
alt={ad.headline || "Advertisement"}
className="absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
/>
) : (
<div className="absolute inset-0 bg-muted flex items-center justify-center">
<Megaphone className="size-6 text-muted-foreground" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
<div className="relative h-full w-full flex items-center xs:p-5 sm:p-4 lg:p-10 text-white overflow-hidden">
<div className="md:w-2xl space-y-4 xs:p-1.5 lg:p-0">
{badgeLabels.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{badgeLabels.map((label, i) => (
<Badge key={i} variant="outline" className="pointer-events-none select-none border-white/50 bg-white/10 text-white">
{label}
</Badge>
))}
</div>
)}
{ad.headline && <div className="pointer-events-none select-none lg:text-5xl xs:text-4xl font-geist font-bold lg:leading-16">{ad.headline}</div>}
{ad.description && <p className="pointer-events-none select-none leading-relaxed text-lg line-clamp-2">{ad.description}</p>}
{ctas.length > 0 && (
<div className="flex gap-3 items-center">
{ctas.map((cta, i) => (
<Button
key={i}
variant={cta.variant === "outline" ? "outline" : "secondary"}
onClick={() => onCtaClick?.(ad, cta)}
>
{cta.label}
</Button>
))}
</div>
)}
</div>
</div>
</div>
)}
</CarouselItem>
);
})}
</CarouselContent>
{/* Navigation and Progress — only meaningful with more than one slide */}
{ads.length > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-2">
<CarouselPrevious className="static translate-y-0 size-8 rounded-lg" />
<CarouselNext className="static translate-y-0 size-8 rounded-lg" />
</div>
<div className="flex-1 max-w-24 ml-4">
<Progress value={progressValue} className="h-2" />
</div>
</div>
)}
</Carousel>
{/* ── Page Builder content Dialog ─────────────────────────────────── */}
<Dialog open={!!viewAd} onOpenChange={(open) => !open && setViewAd(null)}>
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto">
{viewAd && (() => {
const page = viewAd.landing_page ?? {};
const imageSrc = resolveAssetSrc(viewAd.image) || viewAd.image_url || null;
const links = Array.isArray(page.links) ? page.links : [];
return (
<>
<DialogHeader>
<DialogTitle>{page.title || viewAd.headline || "Advertisement"}</DialogTitle>
{page.description && <DialogDescription>{page.description}</DialogDescription>}
</DialogHeader>
{imageSrc && (
<div className="rounded-lg overflow-hidden border h-48">
<img
src={imageSrc}
alt={page.title || viewAd.headline || "Advertisement"}
className="w-full h-full object-cover"
/>
</div>
)}
{page.body && (
<div className="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap">
{page.body}
</div>
)}
{links.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
{links.map((l, i) => (
<Button
key={i}
variant={i === 0 ? "default" : "outline"}
onClick={() => { onCtaClick?.(viewAd, l); setViewAd(null); }}
>
{l.label || l.link}
</Button>
))}
</div>
)}
</>
);
})()}
</DialogContent>
</Dialog>
</div>
);
}
// ── BannerSkeleton ───────────────────────────────────────────────────────────
export function BannerSkeleton() {
return <Skeleton className="w-full rounded-xl h-[220px]" />;
}
@@ -0,0 +1,240 @@
// components/blocks/Hero.jsx
import { useEffect, useState } from "react";
import { Megaphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
import { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel";
import { Progress } from "@/components/ui/progress";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { resolveAssetSrc } from "@/utils/media.util";
// An ad only ever has an internally-authored landing page when it has no
// redirect_link (see applyAdvertisementFields on the backend, which clears
// whichever one the admin didn't choose) — so its presence alone is enough
// to tell whether there's Page Builder content worth surfacing.
function hasLandingPage(ad) {
const page = ad?.landing_page;
if (!page) return false;
return Boolean(page.title || page.description || page.body || page.links?.length);
}
// ── Hero ─────────────────────────────────────────────────────────────────────
/**
* Hero advertisement carousel.
* Each slide is a full-bleed background image with a bottom gradient overlay,
* badge/headline/description/CTAs anchored bottom-left. The whole slide is
* clickable through to the ad's redirect_link/landing page whenever it has no
* CTA buttons of its own. Renders null when no ads are given — callers should
* not fall back to placeholder copy.
*
* Props:
* ads — array of advertisement objects { badge_labels, headline, description, ctas, redirect_link, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-slide click.
*/
export function Hero({ ads, onCtaClick }) {
const [api, setApi] = useState();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
const [viewAd, setViewAd] = useState(null); // ad whose Page Builder content is open in the Dialog
useEffect(() => {
if (!api) return;
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on("select", () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
if (!ads?.length) return null;
const progressValue = count ? (current / count) * 100 : 0;
return (
<div className="w-full">
<Carousel setApi={setApi} className="w-full">
<CarouselContent>
{ads.map((ad) => {
const imageSrc = resolveAssetSrc(ad.image) || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
// Whole-slide click-through only makes sense when there's no CTA
// row of its own to carry that responsibility instead — a landing
// page (Page Builder content) always wins over the plain redirect.
const clickable = ctas.length === 0 && (hasLandingPage(ad) || ad.redirect_link);
const handleSlideClick = () => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, undefined));
return (
<CarouselItem key={ad.advertisement_id}>
<Card className="border rounded-2xl overflow-hidden pl-0 py-0">
<CardContent
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? handleSlideClick : undefined}
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") handleSlideClick(); } : undefined}
className={"relative xs:h-64 lg:h-96 bg-muted flex items-center justify-center" + (clickable ? " cursor-pointer" : "")}
>
{imageSrc ? (
<img
src={imageSrc}
alt={ad.headline || "Advertisement"}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<Megaphone className="size-8 text-muted-foreground" />
)}
{/* Bottom gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
{hasLandingPage(ad) && (
<div className="absolute top-4 right-4">
<Button
variant="secondary"
aria-label="View advertisement details"
onClick={() => setViewAd(ad)}
>
View <Megaphone />
</Button>
</div>
)}
{/* Bottom-left content */}
<div className="absolute bottom-0 left-0 p-6 flex flex-col gap-3 xs:max-w-[280px] sm:max-w-sm lg:max-w-xl">
{badgeLabels.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{badgeLabels.map((label, i) => (
<Badge key={i} variant="outline" className="pointer-events-none select-none w-fit border-white/30 bg-black/20 text-white">
<Megaphone /> {label}
</Badge>
))}
</div>
)}
{ad.headline && (
<div className="font-bold text-white xs:text-2xl lg:text-4xl leading-tight tracking-tighter pointer-events-none select-none">
{ad.headline}
</div>
)}
{ad.description && (
<p className="text-gray-200 xs:text-sm lg:text-md leading-relaxed pointer-events-none select-none line-clamp-2">
{ad.description}
</p>
)}
{ctas.length > 0 && (
<div className="flex items-center gap-2 pt-1">
{ctas.map((cta, i) => (
<Button
key={i}
variant={cta.variant === "outline" ? "outline" : "default"}
className={cta.variant !== "outline" ? "bg-[oklch(0.63_0.25_302)] text-white hover:bg-[oklch(0.63_0.25_302)]/85" : undefined}
onClick={() => onCtaClick?.(ad, cta)}
>
{cta.label}
</Button>
))}
</div>
)}
</div>
</CardContent>
</Card>
</CarouselItem>
);
})}
</CarouselContent>
{/* Navigation and Progress — only meaningful with more than one slide */}
{ads.length > 1 && (
<div className="flex items-center justify-between mt-4">
<div className="flex items-center gap-2">
<CarouselPrevious className="static translate-y-0 size-8 rounded-lg" />
<CarouselNext className="static translate-y-0 size-8 rounded-lg" />
</div>
<div className="flex-1 max-w-24 ml-4">
<Progress value={progressValue} className="h-2" />
</div>
</div>
)}
</Carousel>
{/* ── Page Builder content Dialog ─────────────────────────────────── */}
<Dialog open={!!viewAd} onOpenChange={(open) => !open && setViewAd(null)}>
<DialogContent className="sm:max-w-lg max-h-[80vh] overflow-y-auto">
{viewAd && (() => {
const page = viewAd.landing_page ?? {};
const imageSrc = resolveAssetSrc(viewAd.image) || viewAd.image_url || null;
const links = Array.isArray(page.links) ? page.links : [];
return (
<>
<DialogHeader>
<DialogTitle>{page.title || viewAd.headline || "Advertisement"}</DialogTitle>
{page.description && <DialogDescription>{page.description}</DialogDescription>}
</DialogHeader>
{imageSrc && (
<div className="rounded-lg overflow-hidden border h-48">
<img
src={imageSrc}
alt={page.title || viewAd.headline || "Advertisement"}
className="w-full h-full object-cover"
/>
</div>
)}
{page.body && (
<div className="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap">
{page.body}
</div>
)}
{links.length > 0 && (
<div className="flex flex-wrap gap-2 pt-2">
{links.map((l, i) => (
<Button
key={i}
variant={i === 0 ? "default" : "outline"}
onClick={() => { onCtaClick?.(viewAd, l); setViewAd(null); }}
>
{l.label || l.link}
</Button>
))}
</div>
)}
</>
);
})()}
</DialogContent>
</Dialog>
</div>
);
}
// ── HeroSkeleton ─────────────────────────────────────────────────────────────
export function HeroSkeleton() {
return (
<div className="w-full">
<Card className="border rounded-2xl overflow-hidden pl-0 py-0">
<CardContent className="relative xs:h-64 lg:h-96">
<Skeleton className="absolute inset-0 h-full w-full rounded-none" />
<div className="absolute bottom-0 left-0 p-6 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>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,371 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
import api from "@/utils/api.util";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
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? }
// onWatchProgress(percent) — optional, called (throttled) as playback advances and
// immediately at 100% on end. Backing the watch_percent completion requirement type.
export function AudioBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
const audioRef = useRef(null);
const guard = useMediaWatchGuard({ onWatchProgress, antiSkipEnabled });
const resumedRef = useRef(false);
// ── 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);
guard.reset();
resumedRef.current = false;
// 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(() => {
const el = audioRef.current;
setCurrentTime(el?.currentTime ?? 0);
guard.trackTimeUpdate(el?.currentTime ?? 0, el?.duration);
if (el?.duration) {
const pct = (el.currentTime / el.duration) * 100;
guard.maybeReport(pct);
}
}, [guard]);
const onLoadedMeta = useCallback(() => {
const el = audioRef.current;
const d = el?.duration ?? 0;
setDuration(d);
guard.setDuration(d);
// Resume from the last position reached, once per asset. Left alone once
// fully watched (>=99%) — restarting reads better than resuming at the end.
if (el && !resumedRef.current && resumePercent > 0 && resumePercent < 99 && d) {
resumedRef.current = true;
const resumeSeconds = Math.min((resumePercent / 100) * d, d - 0.25);
el.currentTime = resumeSeconds;
setCurrentTime(resumeSeconds);
// Seeds the seek-cap so forward-seeking within already-watched territory
// works immediately, instead of clamping back to 0 until the next tick.
guard.trackTimeUpdate(resumeSeconds, d);
}
}, [guard, resumePercent]);
const onEnded = useCallback(() => { setPlaying(false); onWatchProgress?.(100); }, [onWatchProgress]);
const onPlay = useCallback(() => {
const el = audioRef.current;
if (el?.duration) guard.reportPlayStart((el.currentTime / el.duration) * 100);
}, [guard]);
const onPause = useCallback(() => {
const el = audioRef.current;
if (el?.duration) guard.flush((el.currentTime / el.duration) * 100);
}, [guard]);
const onSeeking = useCallback(() => guard.markSeeking(), [guard]);
const onSeeked = useCallback(() => guard.markSeeked(), [guard]);
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 = guard.clampSeekTarget(pct * duration);
};
const skip = (secs) => {
const el = audioRef.current;
if (!el) return;
el.currentTime = guard.clampSeekTarget(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 <MediaFallback className="w-full h-28 rounded-xl border border-border" />;
}
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}
onPlay={onPlay}
onPause={onPause}
onSeeking={onSeeking}
onSeeked={onSeeked}
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-primary" />
)}
{/* 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 accent-primary cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer
[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer"
/>
</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,32 @@
import { ImageIcon } from "lucide-react";
import { ZoomableImage } from "@/modules/admin/components/courses/LessonsPreview";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
// content shape: { asset_id?, url?, storage_provider?, alt? }
//
// url is redacted (null) server-side for S3 assets — see redactS3Url() in
// controllers/admin/assets.controller.js. Resolve through useAssetPreviewSrc
// (media.util.js) instead of reading content.url directly, so S3-backed
// images re-mint a fresh stream token/thumbnail at render time.
export function ImageBlock({ content }) {
const { src, loading } = useAssetPreviewSrc(
{ asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url, thumbnail_url: content?.url },
{ scope: "client" },
);
if (loading) {
return <MediaFallback className="aspect-video rounded-lg" />;
}
if (!src) {
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={src} alt={content?.alt} />;
}
@@ -0,0 +1,20 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
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 (
<div className="typeset">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,11 @@
export function TextBlock({ content }) {
if (!content.body) {
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
}
return (
<div
className="typeset text-sm"
dangerouslySetInnerHTML={{ __html: content.body }}
/>
);
}
@@ -0,0 +1,15 @@
import { ImageBlock } from "./ImageBlock";
export function TextImageBlock({ content }) {
const imgLeft = content.image_position === "left";
return (
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
{imgLeft && <ImageBlock content={content} />}
<div
className="typeset 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,17 @@
import { VideoBlock } from "./VideoBlock";
export function TextVideoBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
const vidLeft = content.video_position === "left";
return (
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
{vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />}
<div
className="typeset text-sm w-full"
dangerouslySetInnerHTML={{
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
}}
/>
{!vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />}
</div>
);
}
@@ -0,0 +1,730 @@
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";
import { MediaFallback } from "@/components/generic/MediaFallback";
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
import { formatPlayerTime as fmtTime } from "@/utils/format.util";
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? }
// onWatchProgress(percent) — optional, called (throttled) as playback advances and
// immediately at 100% on end. Backing the watch_percent completion requirement type;
// harmless/unused when the lesson isn't configured for it (caller just won't pass it).
export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
const wrapRef = useRef(null);
const vidRef = useRef(null);
const guard = useMediaWatchGuard({ onWatchProgress, antiSkipEnabled });
const resumedRef = useRef(false);
// ── Stream state ──────────────────────────────────────────────────────────
const [blobUrl, setBlobUrl] = useState(null);
const [freshThumb, setFreshThumb] = 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);
// Native Fullscreen API works on desktop (all browsers) and Android Chrome.
// iOS Safari doesn't support Fullscreen API on arbitrary elements at all, so
// pseudoFullscreen is a CSS-only (fixed inset-0) fallback that keeps our
// custom controls instead of falling back to native <video> fullscreen.
const [nativeFullscreen, setNativeFullscreen] = useState(false);
const [pseudoFullscreen, setPseudoFullscreen] = useState(false);
const isFullscreen = nativeFullscreen || pseudoFullscreen;
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;
// content.thumbnail_url is a presigned S3 URL captured at CMS pick time —
// it expires. For S3 assets, use the fresh thumbnail_url minted alongside
// the stream token below instead; only fall back to the persisted value
// for chibisafe (stable public CDN URL).
const poster = (storageProvider === "s3" ? freshThumb : 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);
setFreshThumb(null);
setFetchError(false);
setPlaying(false);
setProgress(0);
setCurrentTime(0);
setTotalDuration(0);
setOverlayVisible(true);
setSettingsOpen(false);
setEnded(false);
guard.reset();
resumedRef.current = 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, thumbnail_url } = data?.data ?? {};
if (!token) throw new Error("No token returned");
if (!cancelled) {
setBlobUrl(`${API_BASE}/client/media/stream/${token}`);
if (thumbnail_url) setFreshThumb(thumbnail_url);
}
} 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);
guard.trackTimeUpdate(v.currentTime, v.duration);
if (v.duration) {
const pct = (v.currentTime / v.duration) * 100;
setProgress(pct);
guard.maybeReport(pct);
}
};
const onLoaded = () => {
setTotalDuration(v.duration);
guard.setDuration(v.duration);
// Resume from the last position reached, once per asset. Left alone once
// fully watched (>=99%) — restarting reads better than resuming at the end.
if (!resumedRef.current && resumePercent > 0 && resumePercent < 99 && v.duration) {
resumedRef.current = true;
const resumeSeconds = Math.min((resumePercent / 100) * v.duration, v.duration - 0.25);
v.currentTime = resumeSeconds;
setCurrentTime(resumeSeconds);
setProgress(resumePercent);
// Seeds the seek-cap so forward-seeking within already-watched territory
// works immediately, instead of clamping back to 0 until the next tick.
guard.trackTimeUpdate(resumeSeconds, v.duration);
}
};
const onEnded = () => {
setPlaying(false); setOverlayVisible(false); setEnded(true);
onWatchProgress?.(100);
};
const onPlay = () => {
if (v.duration) guard.reportPlayStart((v.currentTime / v.duration) * 100);
};
const onPause = () => {
if (v.duration) guard.flush((v.currentTime / v.duration) * 100);
};
const onSeeking = () => guard.markSeeking();
const onSeeked = () => guard.markSeeked();
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("play", onPlay);
v.addEventListener("pause", onPause);
v.addEventListener("seeking", onSeeking);
v.addEventListener("seeked", onSeeked);
v.addEventListener("progress", onProgress);
if (v.readyState >= 1 && v.duration) onLoaded();
return () => {
v.removeEventListener("waiting", onWaiting);
v.removeEventListener("canplay", onCanPlay);
v.removeEventListener("timeupdate", onTimeUpdate);
v.removeEventListener("loadedmetadata", onLoaded);
v.removeEventListener("ended", onEnded);
v.removeEventListener("play", onPlay);
v.removeEventListener("pause", onPause);
v.removeEventListener("seeking", onSeeking);
v.removeEventListener("seeked", onSeeked);
v.removeEventListener("progress", onProgress);
};
}, [blobUrl]);
useEffect(() => {
const v = vidRef.current;
if (!v) return;
v.playbackRate = speed === "Normal" ? 1 : parseFloat(speed);
}, [speed]);
useEffect(() => {
// Safari (incl. desktop) still fires the prefixed webkitfullscreenchange
// event for element-level fullscreen, so both are listened for.
const onChange = () => {
const active = !!(document.fullscreenElement || document.webkitFullscreenElement);
setNativeFullscreen(active);
// Safari doesn't focus a <div tabIndex> on click, so keyboard
// shortcuts silently stop working after entering fullscreen unless
// we explicitly refocus the wrapper here.
if (active) wrapRef.current?.focus({ preventScroll: true });
};
document.addEventListener("fullscreenchange", onChange);
document.addEventListener("webkitfullscreenchange", onChange);
return () => {
document.removeEventListener("fullscreenchange", onChange);
document.removeEventListener("webkitfullscreenchange", onChange);
};
}, []);
// CSS pseudo-fullscreen fallback (iOS Safari) — lock page scroll and
// reclaim keyboard focus while it's active, restore on exit/unmount.
useEffect(() => {
if (!pseudoFullscreen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
wrapRef.current?.focus({ preventScroll: true });
return () => { document.body.style.overflow = prevOverflow; };
}, [pseudoFullscreen]);
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); }
}, []);
// YouTube-style tap-to-reveal: while playing with the bar auto-hidden, the
// first tap only brings the controls back (doesn't pause); a second tap —
// once controls are already visible — toggles play, same as desktop where
// onMouseMove already keeps controls visible ahead of the click.
const handleWrapperClick = useCallback(() => {
// Safari doesn't focus a <div tabIndex> on click, which silently kills
// keyboard shortcuts afterwards — force it explicitly.
wrapRef.current?.focus({ preventScroll: true });
if (!controlsVisible) { resetHideTimer(); return; }
togglePlay();
resetHideTimer();
}, [controlsVisible, resetHideTimer, togglePlay]);
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 = guard.clampSeekTarget((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 (isFullscreen) {
if (document.fullscreenElement) document.exitFullscreen();
else if (document.webkitFullscreenElement) document.webkitExitFullscreen();
else setPseudoFullscreen(false);
} else if (el.requestFullscreen) {
el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
// Desktop Safari — supports element fullscreen via the prefixed API.
el.webkitRequestFullscreen();
} else {
// iOS Safari has no element-level Fullscreen API at all.
setPseudoFullscreen(true);
}
};
const handleKeyDown = useCallback((e) => {
if (e.target.tagName === "INPUT") return;
// Fold single-char keys to lowercase so Shift/Caps-Lock doesn't break
// letter shortcuts (e.g. Shift+F reporting as "F", not "f").
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
switch (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 = guard.clampSeekTarget(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;
case "Escape":
if (isFullscreen) {
e.preventDefault();
toggleFullscreen();
showFeedback(<Minimize2 className="size-7 text-white" />, "Exit fullscreen");
}
break;
default: break;
}
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback, guard]);
// ── 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 <MediaFallback className="aspect-video rounded-lg" />;
}
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 overflow-hidden bg-black select-none outline-none ${pseudoFullscreen ? "fixed inset-0 z-[100]" : "rounded-lg"}`}
style={{ aspectRatio: isFullscreen ? undefined : "16/9" }}
onMouseMove={resetHideTimer}
onMouseLeave={() => { if (playing) setControlsVisible(false); }}
onClick={handleWrapperClick}
onKeyDown={handleKeyDown}
onContextMenu={(e) => e.preventDefault()}
>
<video
ref={vidRef}
src={blobUrl}
poster={poster}
preload="auto"
playsInline
controlsList="nodownload nofullscreen noremoteplayback"
disablePictureInPicture
disableRemotePlayback
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 disableRemotePlayback 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>
);
}
@@ -0,0 +1,117 @@
import { useNavigate } from "react-router-dom";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { cn } from "@/lib/utils";
/**
* AppBreadcrumb
*
* Generic breadcrumb driven entirely by the `items` prop.
* The last item is always rendered as the current page (non-clickable).
* All preceding items are rendered as clickable links.
*
* ─── Item definition shape ────────────────────────────────────────────────────
*
* @field {string} label Display text
* @field {ReactNode} [icon] Optional icon rendered before the label
* @field {string} [to] navigate() path — if omitted, item is non-navigable
* @field {Function} [onClick] Custom click handler (e, navigate) => void
* Overrides `to` when provided.
*
* ─────────────────────────────────────────────────────────────────────────────
*
* @param {Array} items Ordered list of breadcrumb item definitions
* @param {Object} [color] Independent color overrides per element
* @param {Object} [color.link] Override for non-last (clickable) items
* @param {string} [color.link.color] className applied to BreadcrumbLink
* @param {Object} [color.page] Override for the last (current page) item
* @param {string} [color.page.color] className applied to BreadcrumbPage
*
* ─── Usage ───────────────────────────────────────────────────────────────────
*
* import { House, Users } from "lucide-react";
* import AppBreadcrumb from "@/components/generic/AppBreadcrumb";
*
* // Basic — just paths
* <AppBreadcrumb
* items={[
* { label: "Home", icon: <House className="size-4" />, to: `/admin/${adminId}/users` },
* { label: "Users" },
* ]}
* />
*
* // With custom click handler
* <AppBreadcrumb
* items={[
* { label: "Home", icon: <House className="size-4" />, onClick: (e, navigate) => navigate("/admin") },
* { label: "Settings", to: `/admin/${adminId}/settings` },
* { label: "Profile" },
* ]}
* />
*
* // With per-element color overrides
* <AppBreadcrumb
* color={{ link: { color: "text-muted-foreground" }, page: { color: "text-[#000000]" } }}
* items={items}
* />
*
* ─────────────────────────────────────────────────────────────────────────────
*/
const AppBreadcrumb = ({ items = [], color = {} }) => {
const navigate = useNavigate();
const linkColor = color.link?.color ?? "";
const pageColor = color.page?.color ?? "";
if (!items.length) return null;
return (
<Breadcrumb>
<BreadcrumbList>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<span key={index} className="flex items-center gap-1.5">
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage className={cn("flex items-center gap-2 max-w-[300px] truncate", pageColor)}>
{item.icon}
<span className="hidden lg:inline truncate">{item.label}</span>
<span className="lg:hidden">...</span>
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<div
className={cn("flex items-center gap-2 select-none cursor-pointer max-w-[300px]", linkColor)}
onClick={(e) => {
if (item.onClick) {
item.onClick(e, navigate);
} else if (item.to) {
e.preventDefault();
navigate(item.to);
}
}}
>
{item.icon}
<span className="truncate">{item.label}</span>
</div>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!isLast && <BreadcrumbSeparator />}
</span>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
};
export default AppBreadcrumb;
@@ -0,0 +1,132 @@
// components/generic/BroadcastTargetPicker.jsx
// Single-select searchable picker for notification broadcast targeting.
// Fetches the right list (task lists / courses / tier plans) based on targetType.
import { useEffect, useMemo, useState } from "react";
import { Check, ChevronsUpDown, Search } from "lucide-react";
import api from "@/utils/api.util";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
// ─── Per-target-type data source config ───────────────────────────────────────
const TARGET_CONFIGS = {
task_list: {
fetch: () => api.get("/admin/task-lists", { params: { limit: 100 } }).then((res) => res.data?.data?.data ?? []),
idKey: "task_list_id",
labelKey: "name",
placeholder: "Select a task list…",
},
course: {
fetch: () => api.get("/admin/courses/flat").then((res) => res.data?.data ?? []),
idKey: "uuid",
labelKey: "title",
placeholder: "Select a course…",
},
tier_plan: {
fetch: () => api.get("/admin/tiers", { params: { limit: 100 } }).then((res) => res.data?.data?.data ?? []),
idKey: "plan_id",
labelKey: "label",
placeholder: "Select a subscription plan…",
},
};
export function BroadcastTargetPicker({ targetType, value, onChange, onLabelResolved }) {
const config = TARGET_CONFIGS[targetType];
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
useEffect(() => {
if (!config) return;
let cancelled = false;
setLoading(true);
config.fetch()
.then((data) => { if (!cancelled) setItems(Array.isArray(data) ? data : []); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetType]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q || !config) return items;
return items.filter((item) => String(item[config.labelKey] ?? "").toLowerCase().includes(q));
}, [items, query, config]);
const selected = config ? items.find((item) => String(item[config.idKey]) === String(value)) : undefined;
// Lets the parent (Review step summaries, etc.) show the resolved name
// instead of just the raw id — fires whenever the matched item changes,
// including on initial load once the fetched list resolves `value`.
useEffect(() => {
onLabelResolved?.(selected ? selected[config.labelKey] : null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selected]);
if (!config) return null;
return (
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setQuery(""); }}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal h-9"
>
<span className="truncate">
{selected ? selected[config.labelKey] : config.placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<div className="flex items-center gap-2 border-b px-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search…"
className="flex-1 bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
<div className="max-h-56 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Spinner className="size-4" />
Loading…
</div>
) : filtered.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">No results found.</p>
) : (
filtered.map((item) => {
const id = item[config.idKey];
const isSelected = String(value) === String(id);
return (
<div
key={id}
role="option"
aria-selected={isSelected}
onClick={() => { onChange(id); setOpen(false); setQuery(""); }}
className={cn(
"flex items-center gap-2 px-3 py-2 text-sm cursor-pointer select-none transition-colors hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-accent/50"
)}
>
<Check className={cn("size-3.5 shrink-0", isSelected ? "opacity-100" : "opacity-0")} />
<span className="truncate">{item[config.labelKey]}</span>
</div>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,25 @@
import { useNavigate } from "react-router-dom";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
export default function ClientNotificationBell() {
const { unseenCount } = useClientNotifications();
const navigate = useNavigate();
return (
<Button
variant="outline"
size="icon"
className="relative"
onClick={() => navigate("/notifications")}
>
<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>
);
}
@@ -0,0 +1,463 @@
// ComboBoxCommand.jsx
// ─────────────────────────────────────────────────────────────────────────────
// Generic multi-select combobox with server-search, select-all, embossed
// checkboxes, and +N overflow badge popover.
//
// Props:
// value {any[]} — array of selected IDs (controlled)
// onChange {fn} — called with new array of IDs
// items {object[]} — flat list of option objects
// loading {boolean} — shows spinner while fetching
// onSearch {fn} — called with search term when Search is clicked
//
// // Field mapping — tell the component which keys to read from each item:
// fieldId {string} — unique identifier key default: "id"
// fieldLabel {string} — primary display label key default: "name"
// fieldMeta {string} — secondary mono badge key default: null (hidden)
//
// // Copy overrides (all optional):
// placeholder {string} — trigger placeholder default: "Select items…"
// searchPlaceholder {string} default: "Search…"
// selectAllLabel {string} default: "Select all"
// deselectAllLabel {string} default: "Deselect all"
// emptyLabel {string} default: "No items available."
// unit {string} — singular noun for counts default: "item"
//
// maxVisible {number} — badges before +N collapse default: 3
//
// ── Usage examples ────────────────────────────────────────────────────────────
//
// // Groups (your existing use-case)
// <ComboBoxCommand
// value={field.value}
// onChange={field.onChange}
// items={groups}
// loading={groupsLoading}
// onSearch={onSearchGroups}
// fieldId="group_id"
// fieldLabel="name"
// fieldMeta="group_code"
// placeholder="Select groups…"
// unit="group"
// />
//
// // Users
// <ComboBoxCommand
// value={field.value}
// onChange={field.onChange}
// items={users}
// fieldId="user_id"
// fieldLabel="full_name"
// fieldMeta="email"
// placeholder="Assign users…"
// unit="user"
// />
//
// // Tags (no meta badge)
// <ComboBoxCommand
// value={field.value}
// onChange={field.onChange}
// items={tags}
// fieldId="tag_id"
// fieldLabel="label"
// placeholder="Select tags…"
// unit="tag"
// />
// ─────────────────────────────────────────────────────────────────────────────
import { useState, useRef } from "react";
import { Check, ChevronsUpDown, X, Search, Loader2 } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
// ─── Overflow popover ─────────────────────────────────────────────────────────
function OverflowPopover({ overflow, onRemove, fieldId, fieldLabel, fieldMeta }) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center h-6 px-2 rounded-full text-xs font-medium transition-colors",
"bg-primary/10 text-primary border border-primary/20",
"hover:bg-primary/20 hover:border-primary/40",
open && "bg-primary/20 border-primary/40"
)}
>
+{overflow.length} more
</button>
</PopoverTrigger>
<PopoverContent className="p-0 w-64" align="start" sideOffset={4}>
<div className="px-3 py-2 border-b border-border">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{overflow.length} more selected
</p>
</div>
<div className="p-2 space-y-0.5 max-h-52 overflow-y-auto">
{overflow.map((item) => (
<div
key={item[fieldId]}
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-muted/50 group transition-colors"
>
<span className="flex-1 text-sm truncate">{item[fieldLabel]}</span>
{fieldMeta && item[fieldMeta] && (
<span className="font-mono text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded shrink-0">
{item[fieldMeta]}
</span>
)}
<button
type="button"
onClick={() => onRemove(item[fieldId])}
className={cn(
"shrink-0 rounded-sm p-0.5 opacity-0 group-hover:opacity-60",
"hover:!opacity-100 hover:bg-destructive/20 transition-all"
)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
</PopoverContent>
</Popover>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export function ComboBoxCommand({
// Data
value = [],
onChange,
items = [],
loading = false,
onSearch,
// Field mapping
fieldId = "id",
fieldLabel = "name",
fieldMeta = null, // set to a key string to show the mono badge
// Copy
placeholder = "Select items…",
searchPlaceholder = "Search…",
selectAllLabel = "Select all",
deselectAllLabel = "Deselect all",
emptyLabel = "No items available.",
unit = "item",
// Layout
maxVisible = 3,
}) {
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState("");
const inputRef = useRef(null);
const safeItems = Array.isArray(items) ? items : [];
// ── Client-side filter ─────────────────────────────────────────────────────
const filtered = safeItems.filter((item) => {
if (!inputValue.trim()) return true;
const q = inputValue.toLowerCase();
const matchLabel = String(item[fieldLabel] ?? "").toLowerCase().includes(q);
const matchMeta = fieldMeta
? String(item[fieldMeta] ?? "").toLowerCase().includes(q)
: false;
return matchLabel || matchMeta;
});
// ── Selection state ────────────────────────────────────────────────────────
const selected = safeItems.filter((item) => value.includes(item[fieldId]));
const allSelected = filtered.length > 0 && filtered.every((item) => value.includes(item[fieldId]));
const someSelected = !allSelected && filtered.some((item) => value.includes(item[fieldId]));
const visibleBadges = selected.slice(0, maxVisible);
const overflowBadges = selected.slice(maxVisible);
// ── Handlers ───────────────────────────────────────────────────────────────
const toggle = (id) => {
const key = String(id);
onChange(
value.map(String).includes(key)
? value.filter((v) => String(v) !== key)
: [...value, key]
);
};
const toggleAll = () => {
if (allSelected) {
const filteredIds = new Set(filtered.map((item) => item[fieldId]));
onChange(value.filter((id) => !filteredIds.has(id)));
} else {
const merged = Array.from(new Set([...value, ...filtered.map((item) => item[fieldId])]));
onChange(merged);
}
};
const handleSearch = () => {
if (onSearch && inputValue.trim()) onSearch(inputValue.trim());
};
const handleKeyDown = (e) => {
if (e.key === "Enter" && inputValue.trim()) {
e.preventDefault();
handleSearch();
}
};
// ── Pluralise helper ───────────────────────────────────────────────────────
const plural = (n) => `${n} ${unit}${n !== 1 ? "s" : ""}`;
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
{/* ── Trigger ── */}
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal h-9"
>
<span className="truncate">
{selected.length > 0
? `${plural(selected.length)} selected`
: placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
{/* ── Dropdown ── */}
<PopoverContent className="p-0 w-[480px]" align="start" sideOffset={4}>
<Command shouldFilter={false}>
{/* Search bar */}
<div className="flex items-center gap-1.5 border-b border-border px-2 py-1.5">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground py-1"
/>
<button
type="button"
onClick={handleSearch}
disabled={!inputValue.trim() || loading}
title="Search on server"
className={cn(
"flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
"border border-border bg-muted hover:bg-muted/70",
"disabled:opacity-40 disabled:cursor-not-allowed",
inputValue.trim() && !loading &&
"border-primary/40 bg-primary/10 text-primary hover:bg-primary/15"
)}
>
{loading
? <Loader2 className="h-3 w-3 animate-spin" />
: <Search className="h-3 w-3" />
}
Search
</button>
</div>
<CommandList className="max-h-[300px]">
{/* Loading */}
{loading && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading…
</div>
)}
{/* Empty */}
{!loading && filtered.length === 0 && (
<CommandEmpty className="py-6 text-sm text-muted-foreground text-center">
{inputValue ? `No results for "${inputValue}".` : emptyLabel}
</CommandEmpty>
)}
{!loading && filtered.length > 0 && (
<>
{/* Select All row */}
<div
role="button"
onClick={toggleAll}
className={cn(
"flex items-center gap-3 px-3 py-2.5 cursor-pointer select-none",
"border-b border-border transition-colors hover:bg-muted/50",
(allSelected || someSelected) && "bg-primary/5"
)}
>
{/* Embossed checkbox */}
<span className={cn(
"h-4 w-4 shrink-0 rounded border-2 flex items-center justify-center transition-all",
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.12),inset_0_1px_2px_rgba(0,0,0,0.08)]",
allSelected
? "bg-primary border-primary shadow-none"
: someSelected
? "bg-primary/15 border-primary"
: "bg-background border-border"
)}>
{allSelected && <Check className="h-2.5 w-2.5 text-primary-foreground stroke-[3.5]" />}
{someSelected && <span className="h-1.5 w-1.5 rounded-[2px] bg-primary block" />}
</span>
<span className="text-sm font-semibold text-foreground">
{allSelected ? deselectAllLabel : selectAllLabel}
</span>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
{plural(filtered.length)}
</span>
</div>
{/* Items */}
<CommandGroup>
{filtered.map((item) => {
const id = item[fieldId];
const label = item[fieldLabel];
const meta = fieldMeta ? item[fieldMeta] : null;
const isSelected = value.includes(id);
return (
<CommandItem
key={id}
value={String(id)}
onSelect={() => toggle(id)}
className={cn(
"flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-colors",
"aria-selected:bg-transparent data-selected:bg-transparent",
isSelected
? "bg-primary/[0.06] hover:bg-primary/[0.10]"
: "hover:bg-muted/50"
)}
>
{/* Embossed checkbox */}
<span className={cn(
"h-4 w-4 shrink-0 rounded border-2 flex items-center justify-center transition-all",
isSelected
? "bg-primary border-primary shadow-none ring-2 ring-primary/25 ring-offset-1"
: [
"bg-background border-border",
"shadow-[inset_0_2px_4px_rgba(0,0,0,0.10),inset_0_1px_2px_rgba(0,0,0,0.06)]",
"hover:border-primary/60",
]
)}>
{isSelected && (
<Check className="h-2.5 w-2.5 text-primary-foreground stroke-[3.5]" />
)}
</span>
{/* Label */}
<span className={cn(
"flex-1 truncate text-sm",
isSelected ? "font-medium text-foreground" : "text-foreground/90"
)}>
{label}
</span>
{/* Meta pill */}
{meta && (
<span className={cn(
"font-mono text-[11px] px-1.5 py-0.5 rounded shrink-0",
isSelected
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
{meta}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
{/* Footer */}
{!loading && safeItems.length > 0 && (
<>
<CommandSeparator />
<div className="flex items-center justify-between px-3 py-2 text-[11px] text-muted-foreground">
<span>
{value.length > 0 ? `${plural(value.length)} selected` : "None selected"}
</span>
<span className="tabular-nums">
{filtered.length} / {safeItems.length} shown
</span>
</div>
</>
)}
</Command>
</PopoverContent>
</Popover>
{/* ── Selected badges with +N overflow ── */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center">
{/* First maxVisible badges */}
{visibleBadges.map((item) => (
<Badge
key={item[fieldId]}
variant="secondary"
className="gap-1 pr-1 text-xs h-6"
>
{item[fieldLabel]}
{fieldMeta && item[fieldMeta] && (
<span className="font-mono opacity-50 text-[10px]">{item[fieldMeta]}</span>
)}
<button
type="button"
onClick={() => toggle(item[fieldId])}
className="rounded-sm opacity-60 hover:opacity-100 hover:bg-destructive/20 p-0.5 ml-0.5"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
{/* +N overflow → popover */}
{overflowBadges.length > 0 && (
<OverflowPopover
overflow={overflowBadges}
onRemove={(id) => toggle(id)}
fieldId={fieldId}
fieldLabel={fieldLabel}
fieldMeta={fieldMeta}
/>
)}
{/* Clear all */}
{selected.length > 1 && (
<button
type="button"
onClick={() => onChange([])}
className="text-[11px] text-muted-foreground hover:text-destructive transition-colors underline underline-offset-2 ml-0.5"
>
Clear all
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,72 @@
// components/generic/Dashboard/BarBreakdown.jsx
import { BarChart, Bar, XAxis, YAxis, Tooltip, Cell, ResponsiveContainer } from "recharts";
const DEFAULT_COLORS = [
"#6366f1", "#22c55e", "#f59e0b", "#ef4444",
"#06b6d4", "#a855f7", "#ec4899", "#84cc16",
];
/**
* A horizontal bar chart card for ranked data.
*
* @param {Object} props
* @param {string} props.label Card title
* @param {Array} props.data [{ label, value }]
* @param {Function} [props.onBarClick] (entry) => void — called with the clicked bar
* @param {string[]} [props.colors]
* @param {number} [props.height] Default: 240
* @param {number} [props.yAxisWidth] Default: 130
* @param {string} [props.className]
*
* @example
* <BarBreakdown
* label="Top Groups by Members"
* data={breakdown.data}
* onBarClick={(entry) => navigate(`/admin/users/groups`)}
* />
*/
export function BarBreakdown({
label,
data = [],
onBarClick,
colors = DEFAULT_COLORS,
height = 240,
yAxisWidth = 130,
className = "",
}) {
if (!data.length) return null;
const isClickable = typeof onBarClick === "function";
return (
<div className={`bg-card border rounded-xl p-4 flex flex-col gap-2 ${className}`}>
<p className="text-sm font-medium">{label}</p>
<ResponsiveContainer width="100%" height={height}>
<BarChart
data={data}
layout="vertical"
margin={{ left: 0, right: 24, top: 4, bottom: 4 }}
onClick={isClickable ? ({ activePayload }) => {
if (activePayload?.[0]) onBarClick(activePayload[0].payload);
} : undefined}
style={isClickable ? { cursor: "pointer" } : undefined}
>
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis
type="category"
dataKey="label"
tick={{ fontSize: 11 }}
width={yAxisWidth}
/>
<Tooltip />
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
{data.map((_, i) => (
<Cell key={i} fill={colors[i % colors.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,74 @@
// components/generic/Dashboard/DashboardSection.jsx
import { StatGrid } from "./StatGrid";
import { PieBreakdown } from "./PieBreakdown";
import { BarBreakdown } from "./BarBreakdown";
/**
* Renders a titled section with a stat grid and breakdown charts.
*
* @param {Object} props
* @param {string} props.title
* @param {Array} props.stats [{ key, label, value, icon? }]
* @param {Array} [props.breakdowns] [{ key, label, chartType, data[] }]
* @param {Object} [props.iconMap] { [statKey]: <ReactNode> }
* @param {Object} [props.linkMap] { [statKey]: () => void } — stat card click handlers
* @param {Object} [props.chartLinkMap] { [breakdownKey]: (entry) => void } — chart segment click handlers
* @param {string} [props.className]
*
* @example
* <DashboardSection
* title="Users"
* stats={dashboard.users.stats}
* breakdowns={dashboard.users.breakdowns}
* iconMap={USER_ICON_MAP}
* linkMap={{
* total: () => navigate('/admin/users/all'),
* archived: () => navigate('/admin/users/all/archived'),
* }}
* chartLinkMap={{
* acc_type: (entry) => navigate(`/admin/users/all`),
* }}
* />
*/
export function DashboardSection({
title,
stats = [],
breakdowns = [],
iconMap = {},
linkMap = {},
chartLinkMap = {},
className = "",
}) {
return (
<section className={`flex flex-col gap-4 ${className}`}>
{title && <h2 className="text-base font-medium">{title}</h2>}
{stats.length > 0 && (
<StatGrid stats={stats} iconMap={iconMap} linkMap={linkMap} />
)}
{breakdowns.length > 0 && (
<div className={`grid gap-4 ${breakdowns.length > 1 ? "sm:grid-cols-2" : "grid-cols-1"}`}>
{breakdowns.map((b) =>
b.chartType === "bar" ? (
<BarBreakdown
key={b.key}
label={b.label}
data={b.data}
onBarClick={chartLinkMap[b.key]}
/>
) : (
<PieBreakdown
key={b.key}
label={b.label}
data={b.data}
onSliceClick={chartLinkMap[b.key]}
/>
)
)}
</div>
)}
</section>
);
}
@@ -0,0 +1,69 @@
// components/generic/Dashboard/PieBreakdown.jsx
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from "recharts";
const DEFAULT_COLORS = [
"#6366f1", "#22c55e", "#f59e0b", "#ef4444",
"#06b6d4", "#a855f7", "#ec4899", "#84cc16",
];
/**
* A pie chart card for small-cardinality breakdowns.
*
* @param {Object} props
* @param {string} props.label Card title
* @param {Array} props.data [{ label, value }]
* @param {Function} [props.onSliceClick] (entry) => void — called with the clicked slice
* @param {string[]} [props.colors]
* @param {number} [props.height] Default: 220
* @param {string} [props.className]
*
* @example
* <PieBreakdown
* label="By Account Type"
* data={breakdown.data}
* onSliceClick={(entry) => navigate(`/admin/users/all?acc_type=${entry.label}`)}
* />
*/
export function PieBreakdown({
label,
data = [],
onSliceClick,
colors = DEFAULT_COLORS,
height = 220,
className = "",
}) {
if (!data.length) return null;
const isClickable = typeof onSliceClick === "function";
return (
<div className={`bg-card border rounded-xl p-4 flex flex-col gap-2 ${className}`}>
<p className="text-sm font-medium">{label}</p>
<ResponsiveContainer width="100%" height={height}>
<PieChart>
<Pie
data={data}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
outerRadius={80}
label={({ label: l, percent }) =>
`${l} ${(percent * 100).toFixed(0)}%`
}
labelLine={false}
onClick={isClickable ? (entry) => onSliceClick(entry) : undefined}
cursor={isClickable ? "pointer" : undefined}
>
{data.map((_, i) => (
<Cell key={i} fill={colors[i % colors.length]} />
))}
</Pie>
<Tooltip formatter={(v, n) => [v, n]} />
<Legend />
</PieChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,49 @@
// components/generic/Dashboard/StatCard.jsx
/**
* A single metric card. Optionally clickable for navigation.
*
* @param {Object} props
* @param {string} props.label Display label
* @param {number|string} props.value Metric value
* @param {ReactNode} [props.icon] Optional icon
* @param {Function} [props.onClick] If provided, card becomes clickable
* @param {string} [props.className] Extra classes on the card
*
* @example
* <StatCard
* label="Total Users"
* value={128}
* icon={<Users className="size-5" />}
* onClick={() => navigate('/admin/users/all')}
* />
*/
export function StatCard({ label, value, icon, onClick, className = "" }) {
const isClickable = typeof onClick === "function";
return (
<div
role={isClickable ? "button" : undefined}
tabIndex={isClickable ? 0 : undefined}
onClick={isClickable ? onClick : undefined}
onKeyDown={isClickable ? (e) => e.key === "Enter" && onClick() : undefined}
className={`
bg-card border rounded-xl px-4 py-4 flex items-center gap-4
${isClickable ? "cursor-pointer hover:border-foreground/30 hover:bg-accent transition-colors" : ""}
${className}
`}
>
{icon && (
<div className="shrink-0 bg-muted rounded-lg p-2">
{icon}
</div>
)}
<div className="min-w-0">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground truncate">
{label}
</p>
<p className="text-2xl font-semibold leading-tight">{value ?? 0}</p>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
// components/generic/Dashboard/StatGrid.jsx
import { StatCard } from "./StatCard";
/**
* Renders a responsive grid of StatCards from a stats array.
*
* @param {Object} props
* @param {Array} props.stats [{ key, label, value, icon? }]
* @param {Object} [props.iconMap] { [key]: <ReactNode> }
* @param {Object} [props.linkMap] { [key]: () => void } — maps stat key → navigate callback
* @param {string} [props.className]
*
* @example
* const linkMap = {
* total: () => navigate('/admin/users/all'),
* active: () => navigate('/admin/users/all'),
* archived: () => navigate('/admin/users/all/archived'),
* };
* <StatGrid stats={dashboard.users.stats} iconMap={iconMap} linkMap={linkMap} />
*/
export function StatGrid({ stats = [], iconMap = {}, linkMap = {}, className = "" }) {
return (
<div className={`grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 ${className}`}>
{stats.map((s) => (
<StatCard
key={s.key}
label={s.label}
value={s.value}
icon={s.icon ?? iconMap[s.key]}
onClick={linkMap[s.key]}
/>
))}
</div>
);
}
@@ -0,0 +1,141 @@
// components/generic/Dashboard/TableDashboard.jsx
import { StatCard } from "./StatCard";
import { PieBreakdown } from "./PieBreakdown";
import { BarBreakdown } from "./BarBreakdown";
/**
* A dashboard strip rendered above a DataTable.
* Clicking any stat card or chart segment applies a filter to the table
* via tableRefsRef.current.setFilters([{ id, value }]).
*
* Clicking the same card/segment again clears that filter (toggle).
*
* @param {Object} props
* @param {Array} props.stats
* [{ key, label, value, icon?, filterId?, filterValue? }]
* - filterId: column id to filter on (e.g. "is_active")
* - filterValue: value array to apply (e.g. ["true"])
*
* @param {Array} props.breakdowns
* [{ key, label, chartType, filterId?, data: [{ label, value }] }]
* - filterId: column id for the breakdown (e.g. "acc_type")
* - Each data entry's `label` becomes the filterValue when clicked
*
* @param {Object} props.iconMap { [statKey]: <ReactNode> }
* @param {Array} props.activeFilters filtersRef.current — to highlight active items
* @param {Object} props.tableRefsRef ref with { setFilters, getFilters }
* @param {string} [props.className]
*
* @example — Users table
* <TableDashboard
* stats={dashboard.users.stats}
* breakdowns={dashboard.users.breakdowns}
* iconMap={USER_ICON_MAP}
* activeFilters={tableRefsRef.current.getFilters?.() ?? []}
* tableRefsRef={tableRefsRef}
* />
*/
export function TableDashboard({
stats = [],
breakdowns = [],
statMap = {},
tableRefsRef, // ← remove activeFilters prop entirely
className = "",
}) {
// ─── Always read live from ref ────────────────────────────────────────────
function getActiveFilters() {
return tableRefsRef?.current?.getFilters?.() ?? [];
}
function isFilterActive(filterId, filterValue) {
if (!filterId) return false;
const existing = getActiveFilters().find((f) => f.id === filterId);
if (!existing) return false;
if (!filterValue) return true;
return filterValue.every((v) => existing.value?.includes(v));
}
function toggleFilter(filterId, filterValue) {
if (!filterId || !tableRefsRef?.current?.setFilters) return;
const managedIds = new Set([
...stats.map((s) => s.filterId),
...breakdowns.map((b) => b.filterId),
].filter(Boolean));
const current = getActiveFilters();
const existing = current.find((f) => f.id === filterId);
const isActive = existing &&
(!filterValue || filterValue.every((v) => existing.value?.includes(v)));
const unrelated = current.filter((f) => !managedIds.has(f.id));
if (isActive) {
tableRefsRef.current.setFilters(unrelated);
} else {
tableRefsRef.current.setFilters([
...unrelated,
{ id: filterId, value: filterValue ?? [] },
]);
}
}
function handleStatClick(stat) {
if (stat.onClick) return stat.onClick();
if (!stat.filterId) return;
toggleFilter(stat.filterId, stat.filterValue);
}
function handleChartClick(breakdown, entry) {
if (!breakdown.filterId) return;
toggleFilter(breakdown.filterId, [String(entry.label)]);
}
if (!stats.length && !breakdowns.length) return null;
return (
<div className={`flex flex-col gap-4 mb-4 ${className}`}>
{stats.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{stats.map((s) => {
const active = isFilterActive(s.filterId, s.filterValue);
const mapping = statMap[s.key] ?? {};
return (
<StatCard
key={s.key}
label={mapping.label ?? s.label}
value={s.value}
icon={mapping.icon}
onClick={s.filterId || s.onClick ? () => handleStatClick(s) : undefined}
className={active ? "ring-2 ring-primary border-primary" : ""}
/>
);
})}
</div>
)}
{breakdowns.length > 0 && (
<div className={`grid gap-4 ${breakdowns.length > 1 ? "sm:grid-cols-2" : "grid-cols-1"}`}>
{breakdowns.map((b) =>
b.chartType === "bar" ? (
<BarBreakdown
key={b.key}
label={b.label}
data={b.data}
onBarClick={b.filterId ? (entry) => handleChartClick(b, entry) : undefined}
/>
) : (
<PieBreakdown
key={b.key}
label={b.label}
data={b.data}
onSliceClick={b.filterId ? (entry) => handleChartClick(b, entry) : undefined}
/>
)
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,62 @@
// ─── DashboardGrid.jsx ─────────────────────────────────────────────────────────
import { useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
export default function DashboardGrid({ sections = [] }) {
const navigate = useNavigate();
const handleNavigate = (e, link) => {
e.preventDefault()
navigate(link)
}
return (
<section className="">
<div className="lg:container lg:mx-auto flex flex-col items-start gap-8 p-6">
{sections.map(({ title, description, tiles }) => (
<div key={title} className="flex flex-col items-start gap-4 w-full">
{/* Header */}
<div className="flex flex-col gap-2">
<h1 className="text-2xl leading-tighter font-medium tracking-tighter">
{title}
</h1>
<p className="text-muted-foreground">{description}</p>
</div>
{/* Tiles */}
<div className="grid xs:grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 h-fit w-full gap-4">
{tiles.map(({ key, label, icon: Icon, link }) => (
<motion.div
key={key}
onClick={(e) => handleNavigate(e, link)}
className="group bg-card hover:bg-primary border rounded-lg relative overflow-hidden flex flex-col h-54 cursor-pointer transition-colors duration-200 ease-out"
whileHover={{ y: -6, scale: 1.02 }}
transition={{
y: { type: "spring", stiffness: 300, damping: 20 },
scale: { type: "spring", stiffness: 300, damping: 20 },
}}
>
<motion.div
className="w-full flex justify-end"
whileHover={{ rotate: -18, scale: 1.05 }}
transition={{ type: "spring", stiffness: 200 }}
>
<Icon className="size-32 opacity-50 -rotate-24 text-blue-500 transition-colors duration-200 group-hover:text-white group-hover:opacity-100" />
</motion.div>
<motion.div
className="p-4 font-medium mt-auto text-foreground transition-colors duration-200 ease-out group-hover:text-white"
whileHover={{ y: -2 }}
>
{label}
</motion.div>
</motion.div>
))}
</div>
</div>
))}
</div>
</section>
)
}
@@ -0,0 +1,59 @@
import { useState } from "react";
import { CalendarIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { useDateFormat } from "@/hooks/useDateFormat";
// Single-date picker button used by "From"/"To" range filters (Activity Feed,
// User Activity tab). Extracted so both pages share one implementation.
export function DatePickerButton({ value, onChange, placeholder, disabled }) {
const [open, setOpen] = useState(false);
const { fmtDate } = useDateFormat();
const label = value ? fmtDate(value) : 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>
);
}
@@ -0,0 +1,128 @@
/***********************************************************************************************************************************************************************
* File Name : DeadlinePicker.jsx
* Type : Reusable Component
* Description : Combined date + time picker for deadline fields.
* Controlled via a single ISO datetime string (value / onChange).
* Calendar + time input live inside a Card, matching shadcn's
* "Date and Time Picker" pattern.
*
* Props:
* value : string | null — ISO datetime string e.g. "2026-06-01T10:30"
* onChange : (iso: string | null) => void
* disabled?: boolean
***********************************************************************************************************************************************************************/
import { useState } from 'react';
import { format, parseISO, isValid } from 'date-fns';
import { ChevronDownIcon, Clock2Icon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
// ── Helpers ───────────────────────────────────────────────────────────────────
function toDate(iso) {
if (!iso) return undefined;
const d = parseISO(iso);
return isValid(d) ? d : undefined;
}
function toTimeString(iso) {
if (!iso) return '00:00';
const d = parseISO(iso);
if (!isValid(d)) return '00:00';
return format(d, 'HH:mm');
}
function buildISO(date, timeStr) {
if (!date) return null;
const [h = '00', m = '00'] = (timeStr ?? '00:00').split(':');
const d = new Date(date);
d.setHours(Number(h), Number(m), 0, 0);
return d.toISOString();
}
// ─────────────────────────────────────────────────────────────────────────────
export default function DeadlinePicker({ value, onChange, disabled = false }) {
const [open, setOpen] = useState(false);
const selectedDate = toDate(value);
const timeStr = toTimeString(value);
const handleDateSelect = (date) => {
onChange(buildISO(date, timeStr));
};
const handleTimeChange = (e) => {
onChange(buildISO(selectedDate ?? new Date(), e.target.value));
};
const handleClear = () => onChange(null);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
disabled={disabled}
className="w-56 justify-between font-normal text-sm"
>
{selectedDate
? `${format(selectedDate, 'MMM d, yyyy')} ${format(parseISO(buildISO(selectedDate, timeStr)), 'h:mm a')}`
: 'Select date'}
<ChevronDownIcon className="h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto border-0 bg-transparent p-0 shadow-none ring-0" align="start">
<Card size="sm" className="w-fit">
<CardContent>
<Calendar
mode="single"
selected={selectedDate}
captionLayout="dropdown"
defaultMonth={selectedDate}
onSelect={handleDateSelect}
disabled={disabled}
className="p-0"
/>
</CardContent>
<CardFooter className="flex-col items-stretch gap-3 border-t bg-card">
<FieldGroup>
<Field orientation="horizontal">
<FieldLabel htmlFor="deadline-time">Time</FieldLabel>
<InputGroup>
<InputGroupInput
id="deadline-time"
type="time"
step="60"
value={timeStr}
onChange={handleTimeChange}
disabled={disabled || !selectedDate}
className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
<InputGroupAddon>
<Clock2Icon className="text-muted-foreground" />
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
{value && (
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={handleClear}
>
Clear
</Button>
)}
</CardFooter>
</Card>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,138 @@
// ─── components/ArchiveDialog.jsx ────────────────────────────────────────────
import { useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
import { AlertTriangle } from "lucide-react";
/**
* Generic archive dialog — works for any entity (users, groups, etc.)
*
* Single: <ArchiveDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <ArchiveDialog ids={[1, 2, 3]} entityLabel="Group" ... />
*
* @param {Function} onArchive (id | { ids }) => Promise — called with single id or { ids }
* @param {Function} getName (entity) => string — how to display the entity name
* @param {string} entityLabel e.g. "User", "Group"
* @param {boolean} loading from whichever context the parent uses
* @param {Function} onImpactCheck optional — async () => { label, count }[]
* called when dialog opens (single archive only).
* Returns an array of impact lines to warn about.
* Items with count === 0 are filtered out automatically.
*/
export function ArchiveDialog({
open,
onOpenChange,
entity,
ids,
entityLabel = "Item",
getName,
onArchive,
loading,
onSuccess,
onImpactCheck,
}) {
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const [impactLoading, setImpactLoading] = useState(false);
const [impacts, setImpacts] = useState([]);
const displayName = isBulk
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
: (getName?.(entity) ?? entity?.name ?? entity?.email ?? "this item");
// Fetch impact when dialog opens for a single-entity archive
useEffect(() => {
if (!open || isBulk || !onImpactCheck) {
setImpacts([]);
return;
}
setImpactLoading(true);
setImpacts([]);
onImpactCheck()
.then((rows) => setImpacts((rows ?? []).filter((r) => r.count > 0)))
.catch(() => setImpacts([]))
.finally(() => setImpactLoading(false));
}, [open]);
const handleArchive = async () => {
const res = isBulk
? await onArchive({ ids })
: await onArchive(entity);
if (res) {
onOpenChange(false);
onSuccess?.();
}
};
const hasImpact = impacts.length > 0;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Archive {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</AlertDialogTitle>
{impactLoading ? (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Spinner className="size-4" /> Checking impact…
</div>
) : (
<>
<AlertDialogDescription>
Are you sure you want to archive{" "}
<span className="font-medium text-foreground">{displayName}</span>?{" "}
{isBulk
? "They will be deactivated and lose access immediately."
: "This will deactivate the record immediately."}
</AlertDialogDescription>
{hasImpact && (
<div className="mt-3 rounded-md border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-400 font-medium text-sm">
<AlertTriangle className="h-4 w-4 shrink-0" />
This will affect active learners
</div>
<ul className="ml-5 list-disc text-sm text-amber-800 dark:text-amber-300 space-y-0.5">
{impacts.map((impact, i) => (
<li key={i}>
<span className="font-semibold">{impact.count}</span> {impact.label}
</li>
))}
</ul>
<p className="text-xs text-amber-700 dark:text-amber-400 pt-0.5">
You can still proceed — this cannot be undone without restoring the record.
</p>
</div>
)}
</>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading || impactLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleArchive}
disabled={loading || impactLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading && <Spinner className="size-4 mr-2" />}
Archive{isBulk ? ` ${count} ${entityLabel}${count !== 1 ? "s" : ""}` : ""}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,230 @@
// ─── components/Dialogs/BanUserDialog.jsx ─────────────────────────────────────
import { useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Spinner } from "@/components/ui/spinner";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { CalendarIcon, Clock2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
import { fmtDate } from "@/utils/datetime.util";
const EMPTY = { reason: "", ban_type: "temporary", expires_at: "", _date: null, _time: "12:00" };
/**
* Ban dialog — single or bulk.
*
* Single: <BanUserDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <BanUserDialog ids={[1, 2, 3]} entityLabel="User" ... />
*
* @param {Function} onBan (payload) => Promise — called with { reason, ban_type, expires_at? }
* For bulk, caller merges ids on top.
*/
export function BanUserDialog({
open,
onOpenChange,
entity,
ids,
entityLabel = "User",
getName,
onBan,
loading,
onSuccess,
}) {
const [form, setForm] = useState(EMPTY);
const [errors, setErrors] = useState({});
const [calOpen, setCalOpen] = useState(false);
const mergeDateTime = (date, time) => {
if (!date) return "";
const [h, m] = (time || "12:00").split(":").map(Number);
const d = new Date(date);
d.setHours(h, m, 0, 0);
return d.toISOString();
};
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const displayName = isBulk
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
: (getName?.(entity) ?? entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user");
const validate = () => {
const e = {};
if (!form.reason.trim()) e.reason = "Reason is required.";
if (form.ban_type === "temporary") {
if (!form.expires_at) e.expires_at = "Expiry date is required.";
else if (new Date(form.expires_at) <= new Date()) e.expires_at = "Expiry must be in the future.";
}
setErrors(e);
return Object.keys(e).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
const payload = {
reason: form.reason.trim(),
ban_type: form.ban_type,
...(form.ban_type === "temporary" ? { expires_at: form.expires_at } : {}),
};
const res = await onBan(payload);
if (res) {
setForm(EMPTY);
setErrors({});
onOpenChange(false);
onSuccess?.();
}
};
const handleOpenChange = (v) => {
if (!v) { setForm(EMPTY); setErrors({}); }
onOpenChange(v);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-destructive">
Ban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</DialogTitle>
<DialogDescription>
You are about to ban{" "}
<span className="font-medium text-foreground">{displayName}</span>.
They will be logged out immediately and blocked from accessing the platform.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
{/* Reason */}
<div className="flex flex-col gap-1.5">
<Label htmlFor="ban-reason">
Reason <span className="text-destructive">*</span>
</Label>
<Textarea
id="ban-reason"
placeholder="Explain why this user is being banned…"
rows={3}
value={form.reason}
onChange={(e) => setForm((f) => ({ ...f, reason: e.target.value }))}
aria-invalid={!!errors.reason}
/>
{errors.reason && (
<p className="text-xs text-destructive">{errors.reason}</p>
)}
</div>
{/* Ban type */}
<div className="flex flex-col gap-2">
<Label>Ban Duration</Label>
<RadioGroup
value={form.ban_type}
onValueChange={(v) => setForm((f) => ({ ...f, ban_type: v, expires_at: "" }))}
className="flex gap-6"
>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<RadioGroupItem value="permanent" id="ban-perm" />
<span>Permanent</span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<RadioGroupItem value="temporary" id="ban-temp" />
<span>Temporary</span>
</label>
</RadioGroup>
</div>
{/* Expiry — shown only for temporary */}
{form.ban_type === "temporary" && (
<div className="flex flex-col gap-1.5">
<Label>
Ban Until <span className="text-destructive">*</span>
</Label>
<Popover open={calOpen} onOpenChange={setCalOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-invalid={!!errors.expires_at}
className={cn(
"w-full justify-start text-left font-normal",
!form._date && "text-muted-foreground",
errors.expires_at && "border-destructive"
)}
>
<CalendarIcon className="mr-2 size-4 shrink-0" />
{form._date ? fmtDate(form._date) : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Card size="sm" className="w-fit rounded-none border-0 shadow-none ring-0">
<CardContent>
<Calendar
mode="single"
selected={form._date ?? undefined}
onSelect={(date) => {
const merged = mergeDateTime(date, form._time);
setForm((f) => ({ ...f, _date: date ?? null, expires_at: merged }));
}}
disabled={(d) => d < new Date(Date.now() + 60_000)}
className="p-0"
initialFocus
/>
</CardContent>
<CardFooter className="border-t bg-card">
<FieldGroup>
<Field>
<FieldLabel htmlFor="ban-expires-time">Time</FieldLabel>
<InputGroup>
<InputGroupInput
id="ban-expires-time"
type="time"
step="60"
value={form._time}
onChange={(e) => {
const merged = mergeDateTime(form._date, e.target.value);
setForm((f) => ({ ...f, _time: e.target.value, expires_at: merged }));
}}
className="appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
<InputGroupAddon align="inline-end">
<Clock2Icon className="text-muted-foreground" />
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
</CardFooter>
</Card>
</PopoverContent>
</Popover>
{errors.expires_at && (
<p className="text-xs text-destructive">{errors.expires_at}</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleSubmit}
disabled={loading}
>
{loading && <Spinner className="size-4 mr-2" />}
Ban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,50 @@
// ─── components/generic/Dialogs/Client/InfoDialog.jsx ────────────────────────
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Info } from "lucide-react";
/**
* Generic one-time informational dialog for client-side validation notices —
* e.g. "this course has no assessment yet". Purely informational: a single
* acknowledgement button, no destructive action.
*
* Usage:
* <InfoDialog
* open={open}
* onOpenChange={setOpen}
* title="Assessment Coming Soon"
* description="This course does not have an assessment built yet…"
* />
*/
export function InfoDialog({
open,
onOpenChange,
title,
description,
icon: Icon = Info,
actionLabel = "Continue",
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icon className="size-4 text-muted-foreground shrink-0" />
{title}
</DialogTitle>
<DialogDescription className="text-left">{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => onOpenChange(false)}>{actionLabel}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,59 @@
// ─── components/DemoteAdminDialog.jsx ────────────────────────────────────────
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
/**
* Confirms removing Administrator access from a single user.
*
* @param {Function} onDemoteAdmin (entity) => Promise
*/
export function DemoteAdminDialog({
open,
onOpenChange,
entity,
onDemoteAdmin,
loading,
onSuccess,
}) {
const displayName = entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user";
const handleConfirm = async () => {
const res = await onDemoteAdmin(entity);
if (res) {
onOpenChange(false);
onSuccess?.();
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Demote Administrator</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove Administrator access from{" "}
<span className="font-medium text-foreground">{displayName}</span>?
Their account will be returned to a standard User role, and they will
be notified by email.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} disabled={loading}>
{loading && <Spinner className="size-4 mr-2" />}
Demote
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,59 @@
// ─── components/MakeAdminDialog.jsx ──────────────────────────────────────────
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
/**
* Confirms promoting a single user to Administrator.
*
* @param {Function} onMakeAdmin (entity) => Promise
*/
export function MakeAdminDialog({
open,
onOpenChange,
entity,
onMakeAdmin,
loading,
onSuccess,
}) {
const displayName = entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user";
const handleConfirm = async () => {
const res = await onMakeAdmin(entity);
if (res) {
onOpenChange(false);
onSuccess?.();
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Make Administrator</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to grant{" "}
<span className="font-medium text-foreground">{displayName}</span>{" "}
Administrator access? They will gain full access to all administrative
features, and will be notified by email.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} disabled={loading}>
{loading && <Spinner className="size-4 mr-2" />}
Make Admin
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,141 @@
// ─── components/PermanentDeleteDialog.jsx ────────────────────────────────────
import { useEffect, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
import { AlertTriangle } from "lucide-react";
/**
* Generic permanent-delete dialog — works for any entity (users, groups, etc.)
* Unlike ArchiveDialog, this is irreversible: there is no Restore after this.
*
* Single: <PermanentDeleteDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <PermanentDeleteDialog ids={[1, 2, 3]} entityLabel="Group" ... />
*
* @param {Function} onDelete (id | { ids }) => Promise — called with single id or { ids }
* @param {Function} getName (entity) => string — how to display the entity name
* @param {string} entityLabel e.g. "User", "Group"
* @param {boolean} loading from whichever context the parent uses
* @param {Function} onImpactCheck optional — async () => { label, count }[]
* called when dialog opens (single delete only).
* Returns an array of impact lines to warn about.
* Items with count === 0 are filtered out automatically.
*/
export function PermanentDeleteDialog({
open,
onOpenChange,
entity,
ids,
entityLabel = "Item",
getName,
onDelete,
loading,
onSuccess,
onImpactCheck,
}) {
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const [impactLoading, setImpactLoading] = useState(false);
const [impacts, setImpacts] = useState([]);
const displayName = isBulk
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
: (getName?.(entity) ?? entity?.name ?? entity?.email ?? "this item");
// Fetch impact when dialog opens for a single-entity delete
useEffect(() => {
if (!open || isBulk || !onImpactCheck) {
setImpacts([]);
return;
}
setImpactLoading(true);
setImpacts([]);
onImpactCheck()
.then((rows) => setImpacts((rows ?? []).filter((r) => r.count > 0)))
.catch(() => setImpacts([]))
.finally(() => setImpactLoading(false));
}, [open]);
const handleDelete = async () => {
const res = isBulk
? await onDelete({ ids })
: await onDelete(entity);
if (res) {
onOpenChange(false);
onSuccess?.();
}
};
const hasImpact = impacts.length > 0;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Permanently Delete {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</AlertDialogTitle>
{impactLoading ? (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Spinner className="size-4" /> Checking impact…
</div>
) : (
<>
<AlertDialogDescription>
Are you sure you want to permanently delete{" "}
<span className="font-medium text-foreground">{displayName}</span>?{" "}
This action cannot be undone.
</AlertDialogDescription>
{hasImpact && (
<div className="mt-3 rounded-md border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/40 p-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-400 font-medium text-sm">
<AlertTriangle className="h-4 w-4 shrink-0" />
This will also permanently remove related data
</div>
<ul className="ml-5 list-disc text-sm text-amber-800 dark:text-amber-300 space-y-0.5">
{impacts.map((impact, i) => (
<li key={i}>
<span className="font-semibold">{impact.count}</span> {impact.label}
</li>
))}
</ul>
<p className="text-xs text-amber-700 dark:text-amber-400 pt-0.5">
Deleting anyway will permanently remove all of the above along with it.
</p>
</div>
)}
<div className="mt-3 rounded-md border border-destructive/40 bg-destructive/5 p-2.5 text-xs text-destructive">
This is different from Archive — there is no Restore after this.
</div>
</>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading || impactLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={loading || impactLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{loading && <Spinner className="size-4 mr-2" />}
Delete{isBulk ? ` ${count}` : ""} Permanently
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,83 @@
// ─── components/RestoreDialog.jsx ────────────────────────────────────────────
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Spinner } from "@/components/ui/spinner";
/**
* Generic restore dialog — works for any entity (users, groups, etc.)
*
* Single: <RestoreDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <RestoreDialog ids={[1, 2, 3]} entityLabel="Group" ... />
*
* @param {Function} onRestore (id | { ids }) => Promise — called with single id or { ids }
* @param {Function} getName (entity) => string — how to display the entity name
* @param {string} entityLabel e.g. "User", "Group"
* @param {boolean} loading from whichever context the parent uses
*/
export function RestoreDialog({
open,
onOpenChange,
entity,
ids,
entityLabel = "Item",
getName,
onRestore,
loading,
onSuccess,
}) {
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const displayName = isBulk
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
: (getName?.(entity) ?? entity?.name ?? entity?.email ?? "this item");
const handleRestore = async () => {
const res = isBulk
? await onRestore({ ids })
: await onRestore(entity);
if (res) {
onOpenChange(false);
onSuccess?.();
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Restore {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to restore{" "}
<span className="font-medium text-foreground">{displayName}</span>?{" "}
{isBulk
? "They will regain access to their accounts immediately."
: "This will reactivate the record immediately."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRestore}
disabled={loading}
className="bg-emerald-600 text-white hover:bg-emerald-700"
>
{loading && <Spinner className="size-4 mr-2" />}
Restore{isBulk ? ` ${count} ${entityLabel}${count !== 1 ? "s" : ""}` : ""}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,93 @@
// ─── components/Dialogs/UnbanDialog.jsx ───────────────────────────────────────
import { useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Spinner } from "@/components/ui/spinner";
/**
* Unban dialog — single or bulk.
*
* Single: <UnbanDialog entity={rowObject} getName={(r) => r.name} ... />
* Bulk: <UnbanDialog ids={[1, 2, 3]} entityLabel="User" ... />
*
* @param {Function} onUnban (payload) => Promise — { lift_reason? }
*/
export function UnbanDialog({
open,
onOpenChange,
entity,
ids,
entityLabel = "User",
getName,
onUnban,
loading,
onSuccess,
}) {
const [liftReason, setLiftReason] = useState("");
const isBulk = Array.isArray(ids) && ids.length > 0;
const count = isBulk ? ids.length : 1;
const displayName = isBulk
? `${count} selected ${entityLabel.toLowerCase()}${count !== 1 ? "s" : ""}`
: (getName?.(entity) ?? entity?.personal_info?.name?.full_name ?? entity?.email ?? "this user");
const handleSubmit = async () => {
const res = await onUnban({ lift_reason: liftReason.trim() || undefined });
if (res) {
setLiftReason("");
onOpenChange(false);
onSuccess?.();
}
};
const handleOpenChange = (v) => {
if (!v) setLiftReason("");
onOpenChange(v);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Unban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}</DialogTitle>
<DialogDescription>
Remove the ban on{" "}
<span className="font-medium text-foreground">{displayName}</span>.
They will regain access to the platform immediately.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1.5 py-2">
<Label htmlFor="lift-reason">
Lift Reason <span className="text-muted-foreground text-xs">(optional)</span>
</Label>
<Textarea
id="lift-reason"
placeholder="Reason for lifting this ban…"
rows={3}
value={liftReason}
onChange={(e) => setLiftReason(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button
className="bg-emerald-600 text-white hover:bg-emerald-700"
onClick={handleSubmit}
disabled={loading}
>
{loading && <Spinner className="size-4 mr-2" />}
Unban {isBulk ? `${count} ${entityLabel}${count !== 1 ? "s" : ""}` : entityLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,72 @@
// ─── components/generic/Dialogs/UnsavedChangesDialog.jsx ─────────────────────
import { useRef } from "react";
import { AlertTriangle } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
/**
* Generic "leave without saving?" prompt. Rendered by useUnsavedChangesGuard —
* see that hook for the intended integration (drop its returned `dialog`
* anywhere in the page JSX, no direct usage of this component needed).
*/
export function UnsavedChangesDialog({
open,
onConfirm,
onCancel,
title = "Unsaved changes",
description = "You have unsaved changes. If you leave this page now, they will be lost.",
confirmLabel = "Leave without saving",
cancelLabel = "Stay on this page",
}) {
// Radix's AlertDialogAction/Cancel both auto-dismiss on click (firing
// onOpenChange(false)) *in addition to* their own onClick. Without this
// flag, clicking Action fires onConfirm() then onOpenChange(false) fires
// onCancel() right behind it — the reset immediately undoes the confirm,
// so "Leave without saving" silently does nothing.
const confirmedRef = useRef(false);
const handleConfirm = () => {
confirmedRef.current = true;
onConfirm?.();
};
const handleOpenChange = (next) => {
if (next) return;
if (confirmedRef.current) {
confirmedRef.current = false;
return;
}
onCancel?.();
};
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-amber-500" />
{title}
</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,46 @@
import { useState } from "react";
import { X } from "lucide-react";
// Hardcoded (not DB-driven) — flip to false if it should reappear on every
// reload instead of staying dismissed once closed.
const PERSIST_DISMISSAL = true;
const DISMISS_KEY = "qas_env_banner_dismissed";
export default function EnvironmentBanner() {
const [dismissed, setDismissed] = useState(
() => PERSIST_DISMISSAL && localStorage.getItem(DISMISS_KEY) === "true"
);
if (dismissed) return null;
const handleDismiss = () => {
if (PERSIST_DISMISSAL) localStorage.setItem(DISMISS_KEY, "true");
setDismissed(true);
};
return (
<div role="status" className="w-full shadow-sm px-4 md:px-6 bg-amber-400">
<div className="relative w-full rounded-none py-2 flex items-center justify-center px-10">
<p className="text-sm font-semibold leading-snug truncate text-amber-950">
Development / Staging (QAS) environment — not production
</p>
<div
role="button"
tabIndex={0}
onClick={handleDismiss}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
handleDismiss();
}}
aria-label="Dismiss environment banner"
title="Dismiss"
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity text-amber-950"
>
<X className="size-4" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,306 @@
/***********************************************************************************************************************************************************************
* File Name : FileZoomViewer.jsx
* Type : Component (Generic)
* Description : Zoom/pan/fit viewer for image and PDF files. Used by both the
* client task-attachment preview (FilePreview.jsx, via blob:
* URLs) and the admin asset view pages (ViewImageAsset.jsx,
* ViewDocumentAsset.jsx, via direct stream-token URLs) —
* `src` accepts either, this component doesn't care how the
* URL was produced.
*
* Supported:
* image/jpeg, image/png → <img> with scroll-zoom + drag-pan
* application/pdf → pdf.js renders the current page to
* <canvas>, same zoom/pan controls,
* with page navigation for multi-page PDFs
*
* Not supported (shows a message instead of attempting render):
* DOCX, video, audio, and any other file type — video/audio
* assets use the existing admin VideoBlock/AudioBlock players
* instead (see Blocks/Admin/), not this viewer.
*
* Props:
* src {string} – object URL or direct stream URL
* mimeType {string}
* fileName {string}
* loading {boolean} – true while the parent is still resolving the src
***********************************************************************************************************************************************************************/
import { useState, useRef, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import {
ZoomIn, ZoomOut, Maximize2, ChevronLeft, ChevronRight, FileWarning,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const MIN_SCALE = 0.25;
const MAX_SCALE = 4;
const SCALE_STEP = 0.25;
// ─── Resolve viewer mode from mime type / extension ───────────────────────────
const resolveMode = (mimeType = '', fileName = '') => {
if (mimeType === 'image/jpeg' || mimeType === 'image/png') return 'image';
if (mimeType === 'application/pdf') return 'pdf';
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
if (['jpg', 'jpeg', 'png'].includes(ext)) return 'image';
if (ext === 'pdf') return 'pdf';
return 'unsupported';
};
// ─── Not supported message ─────────────────────────────────────────────────────
const UnsupportedMessage = ({ fileName }) => (
<div className="flex flex-col items-center gap-3 py-16 text-muted-foreground">
<FileWarning className="size-12" />
<p className="text-sm font-medium">Full preview not supported for this file type.</p>
<p className="text-xs max-w-xs text-center">
{fileName ? `"${fileName}" ` : 'This file '}
can't be opened in the zoom viewer. JPEG, PNG, and PDF files are supported.
</p>
</div>
);
// ─── Toolbar ──────────────────────────────────────────────────────────────────
const ZoomToolbar = ({
scale, onZoomIn, onZoomOut, onFit,
page, numPages, onPrevPage, onNextPage,
}) => (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b bg-muted/40">
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-8" onClick={onZoomOut} disabled={scale <= MIN_SCALE} aria-label="Zoom out">
<ZoomOut className="size-4" />
</Button>
<span className="text-xs w-12 text-center tabular-nums select-none">{Math.round(scale * 100)}%</span>
<Button variant="ghost" size="icon" className="size-8" onClick={onZoomIn} disabled={scale >= MAX_SCALE} aria-label="Zoom in">
<ZoomIn className="size-4" />
</Button>
<Button variant="ghost" onClick={onFit} aria-label="Fit to screen">
<Maximize2 className="size-4" /> Fit to screen
</Button>
</div>
{numPages > 1 && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="size-8" onClick={onPrevPage} disabled={page <= 1} aria-label="Previous page">
<ChevronLeft className="size-4" />
</Button>
<span className="text-xs tabular-nums">{page} / {numPages}</span>
<Button variant="ghost" size="icon" className="size-8" onClick={onNextPage} disabled={page >= numPages} aria-label="Next page">
<ChevronRight className="size-4" />
</Button>
</div>
)}
</div>
);
// ─── Shared zoom/pan canvas wrapper ─────────────────────────────────────────────
// Wraps any child (img or canvas) with scroll-to-zoom + drag-to-pan behavior.
const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) => {
const containerRef = useRef(null);
const dragRef = useRef({ dragging: false, startX: 0, startY: 0, origX: 0, origY: 0 });
// ── Scroll to zoom (centered on cursor) ────────────────────────────────────
const onWheel = useCallback((e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -SCALE_STEP : SCALE_STEP;
setScale((s) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, +(s + delta).toFixed(2))));
}, [setScale]);
// ── Drag to pan ───────────────────────────────────────────────────────────
const onPointerDown = (e) => {
dragRef.current = {
dragging: true,
startX: e.clientX,
startY: e.clientY,
origX: offset.x,
origY: offset.y,
};
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e) => {
if (!dragRef.current.dragging) return;
const dx = e.clientX - dragRef.current.startX;
const dy = e.clientY - dragRef.current.startY;
setOffset({ x: dragRef.current.origX + dx, y: dragRef.current.origY + dy });
};
const onPointerUp = (e) => {
dragRef.current.dragging = false;
e.currentTarget.releasePointerCapture(e.pointerId);
};
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [onWheel]);
return (
<div
ref={containerRef}
className={cn(
'relative overflow-hidden bg-muted h-[420px] flex items-center justify-center',
scale > 1 ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={fitFn}
>
<div
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
transformOrigin: 'center center',
transition: dragRef.current.dragging ? 'none' : 'transform 0.1s ease-out',
}}
>
{children}
</div>
</div>
);
};
// ─── Image viewer ───────────────────────────────────────────────────────────────
const ImageViewer = ({ src, fileName }) => {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
return (
<div className="flex flex-col">
<ZoomToolbar
scale={scale}
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, +(s - SCALE_STEP).toFixed(2)))}
onFit={fit}
page={1}
numPages={1}
onPrevPage={() => {}}
onNextPage={() => {}}
/>
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
<img
src={src}
alt={fileName}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
className="max-h-[380px] max-w-none select-none pointer-events-none"
/>
</ZoomPanArea>
</div>
);
};
// ─── PDF viewer (pdf.js → canvas) ───────────────────────────────────────────────
const PdfViewer = ({ src, fileName }) => {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [pdfDoc, setPdfDoc] = useState(null);
const [page, setPage] = useState(1);
const [numPages, setNumPages] = useState(1);
const [rendering, setRendering] = useState(true);
const [loadError, setLoadError] = useState(false);
const canvasRef = useRef(null);
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
// ── Load the PDF document ──────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
(async () => {
try {
const pdfjsLib = await import('pdfjs-dist');
pdfjsLib.GlobalWorkerOptions.workerSrc =
new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
const doc = await pdfjsLib.getDocument({ url: src }).promise;
if (cancelled) return;
setPdfDoc(doc);
setNumPages(doc.numPages);
} catch (err) {
if (!cancelled) setLoadError(true);
}
})();
return () => { cancelled = true; };
}, [src]);
// ── Render current page to canvas ──────────────────────────────────────────
useEffect(() => {
if (!pdfDoc) return;
let cancelled = false;
(async () => {
setRendering(true);
try {
const pdfPage = await pdfDoc.getPage(page);
const viewport = pdfPage.getViewport({ scale: 1.5 }); // base render scale for crispness
const canvas = canvasRef.current;
if (!canvas || cancelled) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
await pdfPage.render({ canvasContext: ctx, viewport }).promise;
} catch {
if (!cancelled) setLoadError(true);
} finally {
if (!cancelled) setRendering(false);
}
})();
return () => { cancelled = true; };
}, [pdfDoc, page]);
if (loadError) return <UnsupportedMessage fileName={fileName} />;
return (
<div className="flex flex-col">
<ZoomToolbar
scale={scale}
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, +(s - SCALE_STEP).toFixed(2)))}
onFit={fit}
page={page}
numPages={numPages}
onPrevPage={() => { setPage((p) => Math.max(1, p - 1)); fit(); }}
onNextPage={() => { setPage((p) => Math.min(numPages, p + 1)); fit(); }}
/>
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
<canvas ref={canvasRef} className="max-h-[380px] select-none" />
</ZoomPanArea>
{rendering && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
<Spinner className="size-6" />
</div>
)}
</div>
);
};
// ─── Main viewer ────────────────────────────────────────────────────────────────
const FileZoomViewer = ({ src, mimeType, fileName, loading }) => {
const mode = resolveMode(mimeType, fileName);
if (loading) {
return (
<div className="flex items-center justify-center h-[420px]">
<Spinner className="size-6" />
</div>
);
}
if (!src || mode === 'unsupported') {
return <UnsupportedMessage fileName={fileName} />;
}
if (mode === 'image') return <ImageViewer src={src} fileName={fileName} />;
if (mode === 'pdf') return <PdfViewer src={src} fileName={fileName} />;
return <UnsupportedMessage fileName={fileName} />;
};
export default FileZoomViewer;
@@ -0,0 +1,311 @@
/***********************************************************************************************************************************************************************
* File Name : GroupMultiSelect.jsx
* Type : Reusable Component
* Description : Searchable multi-select dropdown for User Groups.
* - Portal-based dropdown (escapes Card overflow clipping)
* - First badge + "+N" overflow chip when 2+ selected
* - "Select all" and "Clear" actions in dropdown header
*
* Props:
* value : number[] — selected group_ids
* onChange : (ids: number[]) => void
* groups? : { group_id, name }[] — skip API fetch if provided
* disabled? : boolean
* placeholder?: string
***********************************************************************************************************************************************************************/
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import api from '@/utils/api.util';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { Check, ChevronsUpDown, X, Users, ChevronLeft, ChevronRight } from 'lucide-react';
const PAGE_SIZE = 5;
export default function GroupMultiSelect({
value = [],
onChange,
groups: groupsProp = null,
disabled = false,
placeholder = 'Select groups…',
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [allGroups, setAllGroups] = useState(groupsProp ?? []);
const [loadingGroups, setLoadingGroups] = useState(!groupsProp);
const [dropdownStyle, setDropdownStyle] = useState({});
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
// ── Fetch groups if not provided by parent ────────────────────────────────
useEffect(() => {
if (groupsProp !== null) {
setAllGroups(groupsProp);
setLoadingGroups(false);
return;
}
let cancelled = false;
setLoadingGroups(true);
api.get('/admin/groups', { params: { limit: 500 } })
.then((res) => {
if (!cancelled) {
const raw = res.data?.data?.data ?? res.data?.data ?? [];
setAllGroups(Array.isArray(raw) ? raw : []);
}
})
.catch(() => { if (!cancelled) setAllGroups([]); })
.finally(() => { if (!cancelled) setLoadingGroups(false); });
return () => { cancelled = true; };
}, [groupsProp]);
// ── Position portal dropdown under trigger ────────────────────────────────
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const reposition = () => {
const rect = triggerRef.current.getBoundingClientRect();
setDropdownStyle({
position: 'fixed',
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
};
reposition();
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
return () => {
window.removeEventListener('scroll', reposition, true);
window.removeEventListener('resize', reposition);
};
}, [open]);
// ── Close on outside click ────────────────────────────────────────────────
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (
triggerRef.current?.contains(e.target) ||
dropdownRef.current?.contains(e.target)
) return;
setOpen(false);
setSearch('');
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// ── Helpers ───────────────────────────────────────────────────────────────
const filtered = allGroups.filter((g) =>
g.name.toLowerCase().includes(search.toLowerCase())
);
const selectedGroups = allGroups.filter((g) => value.includes(g.group_id));
const overflowCount = selectedGroups.length - 1;
// All currently visible (filtered) IDs — used for select-all scope
const filteredIds = filtered.map((g) => g.group_id);
const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id));
// ── Pagination ────────────────────────────────────────────────────────────
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const paginated = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
// Reset to page 1 whenever the search term changes
const handleSearchChange = (e) => {
setSearch(e.target.value);
setPage(1);
};
const toggle = (groupId) =>
onChange(value.includes(groupId)
? value.filter((id) => id !== groupId)
: [...value, groupId]
);
const remove = (e, groupId) => {
e.stopPropagation();
onChange(value.filter((id) => id !== groupId));
};
// Select all visible (filtered) groups
const handleSelectAll = () => {
const merged = Array.from(new Set([...value, ...filteredIds]));
onChange(merged);
};
// Clear all selections
const handleClear = () => onChange([]);
// ── Portal dropdown ───────────────────────────────────────────────────────
const dropdown = open && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="rounded-md border border-border bg-popover shadow-lg"
>
{/* Search row */}
<div className="p-2 border-b border-border">
<Input
autoFocus
value={search}
onChange={handleSearchChange}
placeholder="Search groups…"
className="h-8 text-sm"
/>
</div>
{/* Select all / Clear row — only when groups are loaded */}
{!loadingGroups && allGroups.length > 0 && (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/40">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={allFilteredSelected ? handleClear : handleSelectAll}
className="text-xs text-primary hover:underline underline-offset-2 font-medium"
>
{allFilteredSelected ? 'Deselect all' : 'Select all'}
{search && ` (${filteredIds.length})`}
</button>
{value.length > 0 && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
>
Clear ({value.length})
</button>
)}
</div>
)}
{/* Options */}
<ul className="py-1">
{loadingGroups ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
Loading groups…
</li>
) : filtered.length === 0 ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
No groups found.
</li>
) : (
paginated.map((g) => {
const selected = value.includes(g.group_id);
return (
<li
key={g.group_id}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggle(g.group_id)}
className={cn(
'flex items-center gap-2 px-3 py-2 text-sm cursor-pointer select-none',
'hover:bg-accent hover:text-accent-foreground',
selected && 'bg-accent/50'
)}
>
<div className={cn(
'h-4 w-4 rounded border flex items-center justify-center shrink-0',
selected
? 'bg-primary border-primary text-primary-foreground'
: 'border-input'
)}>
{selected && <Check className="h-3 w-3" />}
</div>
<Users className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{g.name}</span>
</li>
);
})
)}
</ul>
{/* Pagination */}
{!loadingGroups && filtered.length > PAGE_SIZE && (
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted/40">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={safePage <= 1}
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
>
<ChevronLeft className="h-3.5 w-3.5" />
Prev
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{safePage} / {totalPages}
</span>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
>
Next
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>,
document.body
);
return (
<>
{/* ── Trigger button ───────────────────────────────────────────── */}
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={() => { setOpen((o) => !o); setSearch(''); setPage(1); }}
className={cn(
'w-full min-h-9 px-3 py-1.5 rounded-md border border-input bg-background text-sm',
'flex items-center gap-1.5 text-left',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
'disabled:opacity-50 disabled:cursor-not-allowed',
open && 'ring-2 ring-ring ring-offset-1'
)}
>
{selectedGroups.length === 0 ? (
<span className="text-muted-foreground flex-1">{placeholder}</span>
) : (
<span className="flex items-center gap-1 flex-1 min-w-0">
{/* Always show only the first selected group */}
<Badge variant="secondary" className="gap-1 text-xs pr-1 shrink-0">
<Users className="h-3 w-3" />
{selectedGroups[0].name}
<span
role="button"
tabIndex={0}
onClick={(e) => remove(e, selectedGroups[0].group_id)}
onKeyDown={(e) => e.key === 'Enter' && remove(e, selectedGroups[0].group_id)}
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5 cursor-pointer"
>
<X className="h-2.5 w-2.5" />
</span>
</Badge>
{/* +N overflow chip */}
{overflowCount > 0 && (
<Badge variant="outline" className="text-xs px-1.5 shrink-0">
+{overflowCount}
</Badge>
)}
</span>
)}
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground shrink-0 ml-auto" />
</button>
{/* ── Portalled dropdown ────────────────────────────────────────── */}
{dropdown}
</>
);
}
@@ -0,0 +1,20 @@
// components/generic/MediaFallback.jsx
//
// Mandatory fallback shown wherever a media element (video/audio/image) is
// still resolving its source — replaces ad-hoc spinners so every player has
// the same loading state.
export function MediaFallback({ className = "" }) {
return (
<div
className={`flex items-center justify-center overflow-hidden ${className}`}
style={{ background: "#FBF7F0" }}
>
<img
src="/media-fallback-patient-tv.svg"
alt="Media is loading"
className="w-full h-full object-contain"
/>
</div>
);
}
@@ -0,0 +1,147 @@
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";
import { useDateFormat } from "@/hooks/useDateFormat";
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`;
}
export default function NotificationBell() {
const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } =
useAdminNotifications();
const { fmtDateTime } = useDateFormat();
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 ? fmtDateTime(selected.createdAt) : ""}</span>
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,101 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Copy, Check, ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { useDateFormat } from "@/hooks/useDateFormat";
import { NotificationIcon, getTypeAccent, getTypeButtonClasses, resolveNotificationLink } from "@/components/generic/notificationDisplay";
export default function NotificationDetailDialog({ notification, onOpenChange }) {
const { fmtDateTime } = useDateFormat();
const navigate = useNavigate();
const [copiedCode, setCopiedCode] = useState(false);
const [navigating, setNavigating] = useState(false);
async function handleCopyCode(code) {
await navigator.clipboard.writeText(code);
setCopiedCode(true);
setTimeout(() => setCopiedCode(false), 2000);
}
const link = notification ? resolveNotificationLink(notification.type, notification.data) : null;
async function handleGo() {
if (!link) return;
setNavigating(true);
await link.go(navigate);
setNavigating(false);
onOpenChange(false);
}
return (
<Dialog open={!!notification} onOpenChange={(open) => { if (!open) { onOpenChange(false); 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 ${getTypeAccent(notification?.type)}`}>
<NotificationIcon
type={notification?.type}
className="h-5 w-5"
/>
</div>
<DialogTitle className="leading-snug">{notification?.title}</DialogTitle>
</div>
<DialogDescription className="text-sm text-foreground/80 leading-relaxed">
{notification?.message}
</DialogDescription>
</DialogHeader>
<Separator />
{notification?.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">
{notification.data.groupCode}
</span>
<Button
size="sm"
variant="outline"
className="shrink-0 gap-1.5"
onClick={() => handleCopyCode(notification.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 />
</>
)}
{link && (
<Button
variant="outline"
className={`w-full gap-1.5 ${getTypeButtonClasses(notification?.type)}`}
onClick={handleGo}
disabled={navigating}
>
{navigating ? <Spinner className="size-4" /> : <>{link.label} <ArrowRight className="size-3.5" /></>}
</Button>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="capitalize">{notification?.type?.replace("_", " ")}</span>
<span>{notification ? fmtDateTime(notification.createdAt) : ""}</span>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,124 @@
/***********************************************************************************************************************************************************************
* File Name: OverflowBadges.jsx
* Type of Program: Generic Component
* Description: Renders up to `max` badges inline. Remaining items collapse into a
* "+N" overflow button that opens a dialog listing all items.
*
* HOW TO USE:
* <OverflowBadges
* items={groups} // array of objects (or primitives)
* labelKey="group_code" // key used for the badge label
* dialogTitleKey="name" // key used for the left side in the dialog row (optional)
* keyKey="group_id" // key used as React key
* dialogTitle="All groups" // dialog heading (optional)
* max={2} // how many badges to show before collapsing (default: 2)
* badgeVariant="outline" // shadcn Badge variant (default: "outline")
* badgeClassName="font-mono text-xs" // extra classes on each badge (optional)
* />
*
* // Primitive arrays (no keys needed):
* <OverflowBadges items={["SALES-A1", "HR-B2", "IT-C3"]} max={2} />
***********************************************************************************************************************************************************************/
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
/**
* @param {object} props
* @param {Array} props.items Array of objects or primitives to render.
* @param {string} [props.labelKey] Key on each item to use as the badge label. Omit for primitive arrays.
* @param {string} [props.dialogTitleKey] Key on each item to show as the row title in the dialog. Falls back to labelKey.
* @param {string} [props.keyKey] Key on each item to use as the React key. Falls back to index.
* @param {string} [props.dialogTitle] Heading shown in the overflow dialog. Default: "All items".
* @param {number} [props.max] Max badges shown inline before collapsing. Default: 2.
* @param {string} [props.badgeVariant] shadcn Badge variant. Default: "outline".
* @param {string} [props.badgeClassName] Extra className on each Badge.
* @param {string} [props.emptyText] Text shown when items is empty. Default: "—".
*/
export function OverflowBadges({
items = [],
labelKey,
dialogTitleKey,
keyKey,
dialogTitle = "All items",
max = 1,
badgeVariant = "outline",
badgeClassName = "text-xs",
emptyText = "—",
}) {
const [open, setOpen] = useState(false);
if (!items.length)
return <span className="text-muted-foreground text-xs">{emptyText}</span>;
const getLabel = (item) =>
labelKey ? item[labelKey] : String(item);
const getTitle = (item) =>
dialogTitleKey ? item[dialogTitleKey] : labelKey ? item[labelKey] : String(item);
const getKey = (item, i) =>
keyKey ? item[keyKey] : i;
const visible = items.slice(0, max);
const overflow = items.slice(max);
return (
<>
<div className="flex flex-wrap items-center gap-1">
{visible.map((item, i) => (
<Badge
key={getKey(item, i)}
variant={badgeVariant}
className={badgeClassName}
>
{getLabel(item)}
</Badge>
))}
{overflow.length > 0 && (
<button
onClick={(e) => { e.stopPropagation(); setOpen(true); }}
className="inline-flex items-center justify-center h-5 px-1.5 rounded-md border border-dashed text-xs text-muted-foreground hover:text-foreground hover:border-foreground transition-colors"
>
+{overflow.length}
</button>
)}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[360px]">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2 pt-1">
{items.map((item, i) => {
const label = getLabel(item);
const title = getTitle(item);
const isSame = label === title;
return (
<div
key={getKey(item, i)}
className="flex items-center justify-between rounded-lg border px-3 py-2"
>
<span className="text-sm font-medium">{title}</span>
{!isSame && (
<Badge variant={badgeVariant} className={badgeClassName}>
{label}
</Badge>
)}
</div>
);
})}
</div>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,142 @@
// components/generic/PlacementSkeleton.jsx
import { cn } from "@/lib/utils";
import { PLACEMENT_LAYOUTS } from "@/data/placementLayouts.data";
// ── Block renderers ────────────────────────────────────────────────────────
const BAR_HEIGHT = {
sm: "h-4",
md: "h-8",
lg: "h-14",
xl: "h-16",
};
function HighlightTag({ label }) {
return (
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-medium text-primary text-center px-1 leading-tight">
{label}
</span>
);
}
function Nav() {
return (
<div className="h-5 rounded bg-muted border flex items-center gap-1 px-2 shrink-0">
<span className="size-1.5 rounded-full bg-muted-foreground/30" />
<span className="size-1.5 rounded-full bg-muted-foreground/30" />
</div>
);
}
function Bar({ size = "md", highlight, label }) {
return (
<div
className={cn(
"relative rounded border shrink-0",
BAR_HEIGHT[size] ?? BAR_HEIGHT.md,
highlight ? "bg-primary/15 border-2 border-primary" : "bg-muted border-border"
)}
>
{highlight && label && <HighlightTag label={label} />}
</div>
);
}
function Filters() {
return (
<div className="flex gap-1.5 shrink-0">
{[1, 2, 3].map((i) => (
<span key={i} className="h-3 w-8 rounded-full bg-muted border" />
))}
</div>
);
}
function Grid({ label }) {
return (
<div className="flex flex-col gap-1 shrink-0">
<div className="flex gap-1.5">
{[1, 2, 3, 4].map((i) => (
<span key={i} className="h-6 flex-1 rounded bg-muted border" />
))}
</div>
{label && <span className="text-[9px] text-muted-foreground text-center">{label}</span>}
</div>
);
}
function ListBlock({ label }) {
return (
<div className="flex flex-col gap-1 shrink-0">
{[1, 2].map((i) => (
<span key={i} className="h-3 rounded bg-muted border" />
))}
{label && <span className="text-[9px] text-muted-foreground text-center">{label}</span>}
</div>
);
}
function Row({ columns = [] }) {
return (
<div className="flex gap-2 shrink-0 flex-1">
{columns.map((col, i) => (
<div key={i} className={cn("relative rounded border h-16", col.width ?? "flex-1", col.highlight ? "bg-primary/15 border-2 border-primary" : "bg-muted border-border")}>
{col.highlight ? (
<HighlightTag label={col.label} />
) : (
col.label && (
<span className="absolute inset-0 flex items-center justify-center text-[9px] text-muted-foreground text-center px-1">
{col.label}
</span>
)
)}
</div>
))}
</div>
);
}
function Block({ block }) {
switch (block.kind) {
case "nav": return <Nav />;
case "bar": return <Bar size={block.size} highlight={block.highlight} label={block.label} />;
case "filters": return <Filters />;
case "grid": return <Grid label={block.label} />;
case "list": return <ListBlock label={block.label} />;
case "row": return <Row columns={block.columns} />;
default: return null;
}
}
// ── PlacementSkeleton ───────────────────────────────────────────────────────
/**
* Lightweight wireframe preview of where an advertisement placement lands on
* its page. Purely a visual aid for the admin creation wizard — not a real
* screenshot, so it never goes stale when the real page's styling changes.
*
* Props:
* placement — a placement registry key, e.g. "dashboard.hero"
*/
export function PlacementSkeleton({ placement }) {
const layout = PLACEMENT_LAYOUTS[placement];
if (!layout) return null;
return (
<div className="relative w-full aspect-video rounded-lg border bg-card p-3 overflow-hidden">
<div className={cn("h-full flex flex-col gap-2", layout.overlay && "opacity-40")}>
{layout.blocks.map((block, i) => (
<Block key={i} block={block} />
))}
</div>
{layout.overlay && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-1/2 h-1/3 rounded-lg bg-primary/15 border-2 border-primary shadow-sm">
<HighlightTag label={layout.overlay.label} />
</div>
</div>
)}
</div>
);
}
+537
View File
@@ -0,0 +1,537 @@
import { useAuth } from '@/contexts/AuthContext'
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 { resolveAssetSrc } from '@/utils/media.util'
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 { 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, 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 { ROLE_CONFIG, AVATAR_COLORS } from '@/data/profile.data'
// ─── Schemas ──────────────────────────────────────────────────────────────────
const addressSchema = z.object({
address_type: z.string().min(1, 'Required'),
street: z.string().min(1, 'Required'),
city: z.string().min(1, 'Required'),
state: z.string().min(1, 'Required'),
zip: z.string().min(1, 'Required'),
country: z.string().min(1, 'Required'),
})
const phoneSchema = z.object({
phone_type: z.string().min(1, 'Required'),
country_code: z.string().min(1, 'Required'),
number: z.string().min(7, 'Required'),
})
// ─── Address Dialog ───────────────────────────────────────────────────────────
function AddressDialog({ open, onClose, initial, onSave }) {
const { register, handleSubmit, control, formState: { errors } } = useForm({
resolver: zodResolver(addressSchema),
defaultValues: initial ?? {
address_type: 'home', street: '', city: '',
state: '', zip: '', country: 'Philippines',
},
})
const onSubmit = (data) => {
onSave({ ...data, full_address: `${data.street}, ${data.city}, ${data.state}, ${data.country}, ${data.zip}` })
onClose()
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{initial ? 'Edit address' : 'Add address'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3 py-1">
<div className="space-y-1.5">
<Label>Address type</Label>
<Controller name="address_type" control={control} render={({ field }) => (
<Select onValueChange={field.onChange} defaultValue={field.value}>
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
<SelectContent>
<SelectItem value="home">Home</SelectItem>
<SelectItem value="work">Work</SelectItem>
<SelectItem value="province">Province</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
)} />
{errors.address_type && <p className="text-xs text-destructive">{errors.address_type.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Street</Label>
<Input placeholder="123 Mabini St" {...register('street')} />
{errors.street && <p className="text-xs text-destructive">{errors.street.message}</p>}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>City</Label>
<Input placeholder="Manila" {...register('city')} />
{errors.city && <p className="text-xs text-destructive">{errors.city.message}</p>}
</div>
<div className="space-y-1.5">
<Label>State / Region</Label>
<Input placeholder="NCR" {...register('state')} />
{errors.state && <p className="text-xs text-destructive">{errors.state.message}</p>}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>ZIP code</Label>
<Input placeholder="1000" {...register('zip')} />
{errors.zip && <p className="text-xs text-destructive">{errors.zip.message}</p>}
</div>
<div className="space-y-1.5">
<Label>Country</Label>
<Input placeholder="Philippines" {...register('country')} />
{errors.country && <p className="text-xs text-destructive">{errors.country.message}</p>}
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">{initial ? 'Save changes' : 'Add address'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ─── Phone Dialog ─────────────────────────────────────────────────────────────
function PhoneDialog({ open, onClose, initial, onSave }) {
const { register, handleSubmit, control, formState: { errors } } = useForm({
resolver: zodResolver(phoneSchema),
defaultValues: initial ?? { phone_type: 'mobile', country_code: '63', number: '' },
})
const onSubmit = (data) => {
onSave({ ...data, full_number: data.country_code + data.number })
onClose()
}
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{initial ? 'Edit phone number' : 'Add phone number'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3 py-1">
<div className="space-y-1.5">
<Label>Phone type</Label>
<Controller name="phone_type" control={control} render={({ field }) => (
<Select onValueChange={field.onChange} defaultValue={field.value}>
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
<SelectContent>
<SelectItem value="mobile">Mobile</SelectItem>
<SelectItem value="home">Home</SelectItem>
<SelectItem value="work">Work</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
)} />
{errors.phone_type && <p className="text-xs text-destructive">{errors.phone_type.message}</p>}
</div>
<div className="flex gap-2">
<div className="space-y-1.5 w-24">
<Label>Code</Label>
<Input placeholder="63" {...register('country_code')} />
{errors.country_code && <p className="text-xs text-destructive">{errors.country_code.message}</p>}
</div>
<div className="space-y-1.5 flex-1">
<Label>Number</Label>
<Input placeholder="9123456789" {...register('number')} />
{errors.number && <p className="text-xs text-destructive">{errors.number.message}</p>}
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">{initial ? 'Save changes' : 'Add number'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// ─── Delete Confirm ───────────────────────────────────────────────────────────
function DeleteConfirm({ open, onClose, onConfirm, label }) {
return (
<AlertDialog open={open} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {label}?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the {label} from your profile. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// ─── 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' }
// 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 ?? [])
const [phones, setPhones] = useState(info?.phone_number ?? [])
const [addrDialog, setAddrDialog] = useState({ open: false, index: null })
const [deleteAddr, setDeleteAddr] = useState({ open: false, index: null })
const [phoneDialog, setPhoneDialog] = useState({ open: false, index: null })
const [deletePhone, setDeletePhone] = useState({ open: false, index: null })
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 = resolveAssetSrc(info?.avatar)
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) {
setAddresses((p) => p.map((a, i) => i === addrDialog.index ? data : a))
} else {
setAddresses((p) => [...p, data])
}
}
const handleDeleteAddress = () => {
setAddresses((p) => p.filter((_, i) => i !== deleteAddr.index))
setDeleteAddr({ open: false, index: null })
}
// ── Phone handlers ──
const handleSavePhone = (data) => {
if (phoneDialog.index !== null) {
setPhones((p) => p.map((ph, i) => i === phoneDialog.index ? data : ph))
} else {
setPhones((p) => [...p, data])
}
}
const handleDeletePhone = () => {
setPhones((p) => p.filter((_, i) => i !== deletePhone.index))
setDeletePhone({ open: false, index: null })
}
return (
<TooltipProvider>
<div className="min-h-screen bg-muted/30 p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
{/* ── Header ── */}
<Card>
<CardContent className="py-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-5">
<div className="relative">
<Avatar className="w-20 h-20">
<AvatarImage src={avatarUrl} alt={fullName} />
<AvatarFallback className={`text-lg font-semibold ${avatarColor}`}>{initials}</AvatarFallback>
</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"
onClick={() => setAvatarDialogOpen(true)}
>
<Camera size={11} />
</button>
</TooltipTrigger>
<TooltipContent>Change photo</TooltipContent>
</Tooltip>
</div>
{/* Name + role */}
<div className="flex-1 min-w-0">
<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">
{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>
{/* ← Outside flex-1, sits at the end */}
<div className="flex gap-2 shrink-0">
{editing ? (
<>
<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
</Button>
</>
) : (
<Button size="sm" variant="outline" onClick={() => setEditing(true)} className="gap-1.5">
<Edit2 size={13} /> Edit profile
</Button>
)}
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* ── Left: Phone + Address ── */}
<div className="md:col-span-1 space-y-4">
{/* Phone numbers */}
<Card>
<CardHeader className="">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Phone size={14} className="text-muted-foreground" />
Phone numbers
{editing && (
<Button size="icon" variant="ghost" className="ml-auto size-6 rounded-md"
onClick={() => setPhoneDialog({ open: true, index: null })}>
<Plus size={13} />
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent>
<Separator className="" />
{phones.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">No phone numbers added.</p>
) : (
<div className="divide-y m-0">
{phones.map((p, i) => (
<div key={i} className="flex items-center gap-2 group py-2.5">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">+{p.country_code} {p.number}</p>
<p className="text-xs text-muted-foreground capitalize">{p.phone_type}</p>
</div>
{editing && (
<div className="flex gap-1 shrink-0">
<Button size="icon" variant="ghost" className="size-6 rounded-md"
onClick={() => setPhoneDialog({ open: true, index: i })}>
<Pencil size={11} />
</Button>
<Button size="icon" variant="ghost" className="size-6 rounded-md text-destructive hover:text-destructive"
onClick={() => setDeletePhone({ open: true, index: i })}>
<Trash2 size={11} />
</Button>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Addresses */}
<Card>
<CardHeader className="">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<MapPin size={14} className="text-muted-foreground" />
Addresses
{editing && (
<Button size="icon" variant="ghost" className="ml-auto size-6 rounded-md"
onClick={() => setAddrDialog({ open: true, index: null })}>
<Plus size={13} />
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent>
<Separator className="" />
{addresses.length === 0 ? (
<p className="text-xs text-muted-foreground py-2">No addresses added.</p>
) : (
<div className="divide-y">
{addresses.map((a, i) => (
<div key={i} className="flex items-start gap-2 group py-2.5">
{a.address_type === 'work'
? <Building2 size={13} className="mt-1 text-muted-foreground shrink-0" />
: <Home size={13} className="mt-1 text-muted-foreground shrink-0" />
}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium capitalize text-muted-foreground">{a.address_type}</p>
<p className="text-sm leading-snug">{a.full_address}</p>
</div>
<div className="flex gap-1 shrink-0">
{editing && (
<div className="flex gap-1 shrink-0">
<Button size="icon" variant="ghost" className="size-6 rounded-md"
onClick={() => setAddrDialog({ open: true, index: i })}>
<Pencil size={11} />
</Button>
<Button size="icon" variant="ghost" className="size-6 rounded-md text-destructive hover:text-destructive"
onClick={() => setDeleteAddr({ open: true, index: i })}>
<Trash2 size={11} />
</Button>
</div>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* ── Right column ── */}
<div className="md:col-span-2 space-y-6">
{isClient ? (
<>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Activity size={14} className="text-muted-foreground" />
Recent activities
</CardTitle>
</CardHeader>
<CardContent>
<Separator className="mb-1" />
<p className="text-xs text-muted-foreground py-2">No recent activities.</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Trophy size={14} className="text-muted-foreground" />
Achievements
</CardTitle>
</CardHeader>
<CardContent>
<Separator className="mb-3" />
<p className="text-xs text-muted-foreground py-2">No achievements yet.</p>
</CardContent>
</Card>
</>
) : (
<Card className="h-full">
<CardContent className="flex flex-col items-center justify-center text-center gap-3 min-h-48 pt-6">
<Shield size={32} className="text-muted-foreground/30" />
<div>
<p className="text-sm font-medium">Nothing else to show</p>
<p className="text-xs text-muted-foreground mt-1">
Activities and achievements are only visible to clients.
</p>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
{/* ── Dialogs ── */}
<AddressDialog
open={addrDialog.open}
onClose={() => setAddrDialog({ open: false, index: null })}
initial={addrDialog.index !== null ? addresses[addrDialog.index] : null}
onSave={handleSaveAddress}
/>
<DeleteConfirm
open={deleteAddr.open}
onClose={() => setDeleteAddr({ open: false, index: null })}
onConfirm={handleDeleteAddress}
label="address"
/>
<PhoneDialog
open={phoneDialog.open}
onClose={() => setPhoneDialog({ open: false, index: null })}
initial={phoneDialog.index !== null ? phones[phoneDialog.index] : null}
onSave={handleSavePhone}
/>
<DeleteConfirm
open={deletePhone.open}
onClose={() => setDeletePhone({ open: false, index: null })}
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,282 @@
import { useState } from 'react';
import { Plus, Trash2, GripVertical, Link, Upload, BookOpen, Layers, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
// ─── Requirement type config ──────────────────────────────────────────────────
const REQUIREMENT_TYPES = [
{ value: 'visit_link', label: 'Visit a Link', icon: Link, category: 'Action' },
{ value: 'upload_file', label: 'Upload a File', icon: Upload, category: 'Action' },
{ value: 'read_course', label: 'Read a Course', icon: BookOpen, category: 'Content' },
{ value: 'read_unit', label: 'Read a Unit', icon: Layers, category: 'Content' },
{ value: 'read_lesson', label: 'Read a Lesson', icon: FileText, category: 'Content' },
];
const TYPE_MAP = Object.fromEntries(REQUIREMENT_TYPES.map((t) => [t.value, t]));
const FILE_TYPE_OPTIONS = [
{ value: 'pdf', label: 'PDF' },
{ value: 'docx', label: 'DOCX' },
{ value: 'xlsx', label: 'XLSX' },
{ value: 'png', label: 'PNG' },
{ value: 'jpg', label: 'JPG' },
{ value: 'mp4', label: 'MP4' },
{ value: 'zip', label: 'ZIP' },
];
// ─── Empty requirement factory ────────────────────────────────────────────────
function createRequirement(type = 'visit_link') {
return {
_key: crypto.randomUUID(),
type,
// visit_link
link_url: '',
link_label: '',
// upload_file
allowed_file_types: [],
max_file_count: 1,
// read_*
reference_id: '',
reference_label: '',
};
}
// ─── RequirementBuilder ───────────────────────────────────────────────────────
export default function RequirementBuilder({ value = [], onChange, courses = [], units = [], lessons = [] }) {
const [items, setItems] = useState(
value.length > 0
? value.map((r) => ({ _key: crypto.randomUUID(), ...r }))
: []
);
const emit = (next) => {
setItems(next);
// strip _key before calling onChange
onChange?.(next.map(({ _key, ...r }) => r));
};
const addItem = () => emit([...items, createRequirement('visit_link')]);
const removeItem = (key) => emit(items.filter((i) => i._key !== key));
const updateItem = (key, patch) =>
emit(items.map((i) => (i._key === key ? { ...i, ...patch } : i)));
const toggleFileType = (key, ft) => {
const item = items.find((i) => i._key === key);
if (!item) return;
const current = item.allowed_file_types ?? [];
const next = current.includes(ft)
? current.filter((t) => t !== ft)
: [...current, ft];
updateItem(key, { allowed_file_types: next });
};
return (
<div className="space-y-3">
{items.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-6 border border-dashed rounded-lg">
No requirements added. Click "Add Requirement" to start.
</p>
)}
{items.map((item, idx) => {
const typeDef = TYPE_MAP[item.type];
const Icon = typeDef?.icon ?? Link;
return (
<Card key={item._key} className="relative">
<CardContent className="pt-4 pb-4 space-y-3">
{/* Header row */}
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Icon className="h-3 w-3" />
{idx + 1}
</Badge>
{/* Type selector */}
<Select
value={item.type}
onValueChange={(v) => updateItem(item._key, { type: v })}
>
<SelectTrigger className="h-8 text-sm flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{REQUIREMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
<span className="flex items-center gap-2">
<t.icon className="h-3.5 w-3.5" />
{t.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-destructive hover:text-destructive"
onClick={() => removeItem(item._key)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* ── visit_link fields ── */}
{item.type === 'visit_link' && (
<div className="grid grid-cols-2 gap-3 pl-7">
<div className="space-y-1">
<Label className="text-xs">URL <span className="text-destructive">*</span></Label>
<Input
placeholder="https://example.com"
value={item.link_url}
onChange={(e) => updateItem(item._key, { link_url: e.target.value })}
className="h-8 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Label (optional)</Label>
<Input
placeholder="Link description"
value={item.link_label}
onChange={(e) => updateItem(item._key, { link_label: e.target.value })}
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* ── upload_file fields ── */}
{item.type === 'upload_file' && (
<div className="pl-7 space-y-3">
<div className="space-y-1">
<Label className="text-xs">Allowed File Types</Label>
<div className="flex flex-wrap gap-2">
{FILE_TYPE_OPTIONS.map((ft) => (
<Badge
key={ft.value}
variant={(item.allowed_file_types ?? []).includes(ft.value) ? 'default' : 'outline'}
className="cursor-pointer select-none text-xs"
onClick={() => toggleFileType(item._key, ft.value)}
>
{ft.label}
</Badge>
))}
</div>
</div>
<div className="space-y-1 w-32">
<Label className="text-xs">Max Files</Label>
<Input
type="number"
min={1}
max={20}
value={item.max_file_count}
onChange={(e) => updateItem(item._key, { max_file_count: parseInt(e.target.value) || 1 })}
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* ── read_course / read_unit / read_lesson fields ── */}
{['read_course', 'read_unit', 'read_lesson'].includes(item.type) && (
<div className="pl-7 space-y-1">
<Label className="text-xs">
{item.type === 'read_course' ? 'Course' : item.type === 'read_unit' ? 'Unit' : 'Lesson'}
</Label>
{/* Reference selector */}
{item.type === 'read_course' && (
<Select
value={item.reference_id}
onValueChange={(v) => {
const course = courses.find((c) => c.course_id === v);
updateItem(item._key, {
reference_id: v,
reference_label: course?.title ?? '',
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select a course" />
</SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={c.course_id} value={c.course_id}>
{c.title}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{item.type === 'read_unit' && (
<Select
value={item.reference_id}
onValueChange={(v) => {
const unit = units.find((u) => u.unit_id === v);
updateItem(item._key, {
reference_id: v,
reference_label: unit?.title ?? '',
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select a unit" />
</SelectTrigger>
<SelectContent>
{units.map((u) => (
<SelectItem key={u.unit_id} value={u.unit_id}>
{u.title}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{item.type === 'read_lesson' && (
<Select
value={item.reference_id}
onValueChange={(v) => {
const lesson = lessons.find((l) => l.lesson_id === v);
updateItem(item._key, {
reference_id: v,
reference_label: lesson?.title ?? '',
});
}}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="Select a lesson" />
</SelectTrigger>
<SelectContent>
{lessons.map((l) => (
<SelectItem key={l.lesson_id} value={l.lesson_id}>
{l.title}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
</CardContent>
</Card>
);
})}
<Button type="button" variant="outline" size="sm" className="w-full gap-2" onClick={addItem}>
<Plus className="h-4 w-4" />
Add Requirement
</Button>
</div>
);
}
@@ -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;
@@ -0,0 +1,243 @@
// components/generic/Sheet/AddUsersSheet.jsx
import { useState, useEffect, useMemo } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import { AlertTriangle } from "lucide-react";
/**
* Generic sheet for selecting and adding users to any entity
* (groups, tasks, projects, etc.)
*
* @param {Object} props
* @param {boolean} props.open
* @param {Function} props.onOpenChange
*
* @param {string} [props.title] Sheet heading. Default: "Add members"
* @param {string} [props.submitLabel] Submit button label. Default: "Add"
*
* @param {Array} props.users [{ [idKey], [labelKey] }] — list to display
* @param {boolean} props.loading
* @param {Function} props.onFetch Called on open to (re)load the list
*
* @param {string} [props.idKey] Key for the user id. Default: "user_id"
* @param {string} [props.labelKey] Key for the display name. Default: "full_name"
* @param {string} [props.subLabelKey] Optional secondary line (e.g. "email")
* @param {string} [props.warningKey] If set, users with a truthy value at this key show
* a "will be moved" warning (e.g. "current_group")
*
* @param {Function} props.onSubmit Called with selected ids[]
*
* @example — groups
* <AddUsersSheet
* title="Add members"
* users={usersNotIn}
* onFetch={() => fetchUsersNotInGroup(gid)}
* onSubmit={(ids) => addUsersToGroup(gid, ids)}
* ...
* />
*
* @example — tasks
* <AddUsersSheet
* title="Assign users"
* users={unassignedUsers}
* onFetch={() => fetchUnassignedUsers(taskId)}
* onSubmit={(ids) => assignUsersToTask(taskId, ids)}
* idKey="user_id"
* labelKey="full_name"
* subLabelKey="email"
* ...
* />
*/
export function AddSheet({
open,
onOpenChange,
title = "Add members",
submitLabel = "Add",
users = [],
loading = false,
onFetch,
idKey = "user_id",
labelKey = "full_name",
subLabelKey = null,
warningKey = null,
onSubmit,
}) {
const [search, setSearch] = useState("");
const [selected, setSelected] = useState([]);
useEffect(() => {
if (open) {
onFetch?.();
setSearch("");
setSelected([]);
}
}, [open]);
const filtered = useMemo(() => {
if (!search.trim()) return users;
return users.filter((u) =>
String(u[labelKey] ?? "").toLowerCase().includes(search.toLowerCase())
);
}, [search, users, labelKey]);
const toggle = (id) =>
setSelected((prev) =>
prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]
);
const toggleAll = () => {
const allIds = filtered.map((u) => u[idKey]);
const allSelected = allIds.every((id) => selected.includes(id));
setSelected((prev) =>
allSelected
? prev.filter((id) => !allIds.includes(id))
: [...new Set([...prev, ...allIds])]
);
};
const allFilteredSelected =
filtered.length > 0 && filtered.every((u) => selected.includes(u[idKey]));
const movingCount = useMemo(() => {
if (!warningKey) return 0;
return selected.filter((id) => {
const user = users.find((u) => u[idKey] === id);
return !!user?.[warningKey];
}).length;
}, [selected, users, warningKey, idKey]);
async function handleSubmit() {
if (!selected.length) return;
await onSubmit(selected);
onOpenChange(false);
}
function handleClose() {
setSearch("");
setSelected([]);
onOpenChange(false);
}
return (
<Sheet open={open} onOpenChange={handleClose}>
{/*
SheetContent is a flex column with fixed height (100dvh).
We split it into 3 rows: header (shrink-0), body (flex-1 overflow-hidden), footer (shrink-0).
The body itself is a flex column — search and select-all shrink, list overflows.
*/}
<SheetContent side="right" className="w-[400px] flex flex-col h-full p-0">
{/* Header */}
<SheetHeader className="shrink-0 border-b px-6 py-4">
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
{/* Body — fills remaining space, clips overflow */}
<div className="flex-1 min-h-0 flex flex-col gap-3 px-6 py-4 relative">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm z-10">
<Spinner className="size-8" />
</div>
)}
{/* Search — fixed height */}
<div className="shrink-0">
<Input
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Select all — fixed height */}
{filtered.length > 0 && (
<label className="shrink-0 flex items-center gap-2 text-sm font-medium cursor-pointer select-none border-b pb-3">
<input
type="checkbox"
checked={allFilteredSelected}
onChange={toggleAll}
/>
Select all ({filtered.length})
</label>
)}
{/* Scrollable list — takes all remaining space */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-1 pr-1">
{!loading && filtered.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
{search ? `No results for "${search}".` : "No users available."}
</p>
) : (
filtered.map((user) => {
const id = user[idKey];
const label = user[labelKey];
const sub = subLabelKey ? user[subLabelKey] : null;
const warning = warningKey ? user[warningKey] : null;
return (
<label
key={id}
className="flex items-center gap-3 p-1 rounded-md hover:bg-muted cursor-pointer text-sm select-none"
>
<input
type="checkbox"
checked={selected.includes(id)}
onChange={() => toggle(id)}
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="truncate">{label}</span>
{sub && (
<span className="text-xs text-muted-foreground truncate">
{sub}
</span>
)}
{warning && (
<span className="text-xs text-amber-600 flex items-center gap-1 mt-0.5">
<AlertTriangle className="size-3 shrink-0" />
Also in: {warning}
</span>
)}
</div>
</label>
);
})
)}
</div>
</div>
</div>
{/* Footer */}
<div className="shrink-0 flex flex-col gap-2 border-t px-6 py-4">
{movingCount > 0 && (
<p className="text-xs text-amber-600 flex items-center gap-1.5">
<AlertTriangle className="size-3 shrink-0" />
{movingCount} user{movingCount > 1 ? 's' : ''} already belong{movingCount === 1 ? 's' : ''} to other group{movingCount > 1 ? 's' : ''}.
</p>
)}
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={handleClose}>
Cancel
</Button>
<Button
className="flex-1"
disabled={!selected.length || loading}
onClick={handleSubmit}
>
{selected.length > 0 ? `${submitLabel} (${selected.length})` : submitLabel}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,215 @@
import { useState, useEffect, useMemo } from "react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
// ─── Field-specific display maps ──────────────────────────────────────────────
const FIELD_DISPLAY_MAP = {
is_active: { true: "Active", false: "Inactive" },
is_verified: { true: "Verified", false: "Not Verified" },
is_banned: { true: "Banned", false: "Not Banned" },
is_public: { true: "Public", false: "Private" },
is_required: { true: "Yes", false: "No" },
};
const BOOLEAN_FIELDS = Object.keys(FIELD_DISPLAY_MAP);
// ─── Generic formatter ────────────────────────────────────────────────────────
// Audit fields (createdBy/updatedBy/deletedBy) come back from the field-values
// API as { value: user_id, label: full_name } — filtering has to select on the
// id, but the sheet should still display the name. Every other field type
// still hands this plain primitives, which pass through unchanged.
const itemValue = (item) => (item && typeof item === "object" && "value" in item) ? item.value : item;
const itemLabel = (item) => (item && typeof item === "object" && "label" in item) ? item.label : item;
const formatFilterItem = (item, field, type, fmtDate) => {
const label = itemLabel(item);
if (FIELD_DISPLAY_MAP[field]) {
return FIELD_DISPLAY_MAP[field][String(label)] ?? label;
}
if (type === "date" && label) {
return fmtDate(label);
}
return label;
};
// ─── Reusable empty state ─────────────────────────────────────────────────────
const EmptyState = ({ search }) => (
<p className="text-sm text-muted-foreground text-center py-4">
{search ? `No results for "${search}".` : "No data found."}
</p>
);
// ─── Reusable item list renderer ──────────────────────────────────────────────
const FilterList = ({ items, field, type, selected, onToggle, inputType = "checkbox" }) => {
const { fmtDate } = useDateFormat();
if (items.length === 0) return <EmptyState />;
return items.map((item) => {
const value = itemValue(item);
return (
<label key={value} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type={inputType}
name={inputType === "radio" ? field : undefined}
checked={selected.includes(String(value))}
onChange={() => onToggle(value)}
/>
{formatFilterItem(item, field, type, fmtDate)}
</label>
);
});
};
export function FilterSheet({ open, onOpenChange, column, attr, data = [], loading }) {
const type = attr?.type;
const field = attr?.field;
const enumData = attr?.options?.choices || [];
const [search, setSearch] = useState("");
const [selected, setSelected] = useState([]);
useEffect(() => {
const val = column?.getFilterValue();
setSelected(Array.isArray(val) ? val.map(String) : []);
}, [column]);
const sourceData = useMemo(() => {
if (BOOLEAN_FIELDS.includes(field)) return ["true", "false"];
if (type === "enum") return enumData;
return data || [];
}, [field, type, enumData, data]);
const filteredData = useMemo(() => {
if (!search) return sourceData;
return sourceData.filter((item) =>
formatFilterItem(item, field, type)
.toString()
.toLowerCase()
.includes(search.toLowerCase())
);
}, [search, sourceData, field, type]);
const toggle = (item) => {
setSelected((prev) =>
prev.includes(String(item))
? prev.filter((v) => v !== String(item))
: [...prev, String(item)]
);
};
const toggleRadio = (item) => setSelected([String(item)]);
const isBoolean = BOOLEAN_FIELDS.includes(field);
const isEnum = type === "enum" && !isBoolean;
const isList = !isBoolean && !isEnum;
const isEmpty = !loading && sourceData.length === 0;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-[380px] flex flex-col">
<SheetHeader className="shrink-0 border-b">
<SheetTitle>Filter by {column?.columnDef?.header}</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-hidden flex flex-col gap-4 px-4 relative">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm z-10">
<Spinner className="size-8" />
</div>
)}
{/* ================= EMPTY STATE ================= */}
{isEmpty && <EmptyState />}
{/* ================= BOOLEAN ================= */}
{!isEmpty && isBoolean && (
<div className="space-y-2 overflow-auto pr-2 pt-2">
<FilterList
items={sourceData}
field={field}
type={type}
selected={selected}
onToggle={toggleRadio}
inputType="radio"
/>
</div>
)}
{/* ================= ENUM ================= */}
{!isEmpty && isEnum && (
<div className="space-y-2 overflow-auto pr-2 pt-2">
<FilterList
items={sourceData}
field={field}
type={type}
selected={selected}
onToggle={toggle}
/>
</div>
)}
{/* ========== TEXT / NUMBER / DATE ========== */}
{!isEmpty && isList && (
<>
<div>
<Input
placeholder={`Search ${type}...`}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<ScrollArea className="flex-1 h-[550px] pr-2">
<div className="space-y-2">
{filteredData.length === 0
? <EmptyState search={search} />
: <FilterList
items={filteredData}
field={field}
type={type}
selected={selected}
onToggle={toggle}
/>
}
</div>
<ScrollBar orientation="vertical" />
</ScrollArea>
</>
)}
</div>
{/* ─── Footer — hidden when empty ──────────────────────────────────── */}
{!isEmpty && (
<div className="shrink-0 flex gap-2 border-t p-4">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setSelected([]);
column.setFilterValue(undefined);
onOpenChange(false);
}}
>
Clear
</Button>
<Button
className="flex-1"
onClick={() => {
column.setFilterValue(selected);
onOpenChange(false);
}}
>
Apply
</Button>
</div>
)}
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,77 @@
import { X } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { resolveNotificationLink } from "@/components/generic/notificationDisplay";
import { getTierColor, getContrastText } from "@/utils/tierColors";
// Up to 2 sticky alerts shown at once, stacked — no dialog on click anymore;
// the centered title text itself is the click target and redirects straight
// to the alert's link (if any).
const MAX_VISIBLE_STICKY = 2;
function resolveClickAction(stickyAnnouncement) {
return resolveNotificationLink(stickyAnnouncement.type, stickyAnnouncement.data);
}
function StickyAnnouncementRow({ announcement, onDismiss }) {
const navigate = useNavigate();
const swatch = getTierColor(announcement.color || "indigo").swatch;
const textColor = getContrastText(swatch, announcement.color || "indigo");
const clickAction = resolveClickAction(announcement);
return (
<div role="status" className="w-full shadow-sm px-4 md:px-6" style={{ backgroundColor: swatch }}>
<div className="relative w-full rounded-none py-3 flex items-center justify-center px-10">
<p
className={["text-sm font-semibold leading-snug truncate", clickAction ? "cursor-pointer" : ""].join(" ")}
style={{ color: textColor }}
onClick={clickAction ? () => clickAction.go(navigate) : undefined}
>
{announcement.title || "Announcement"}
</p>
{/* Only way to dismiss a sticky alert. */}
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
void onDismiss();
}}
aria-label="Dismiss sticky announcement"
title="Dismiss"
className="absolute right-2 inline-flex items-center justify-center size-8 rounded-lg cursor-pointer hover:opacity-70 transition-opacity"
style={{ color: textColor }}
>
<X className="size-4" />
</div>
</div>
</div>
);
}
export default function StickyAnnouncementBar() {
const { stickyAnnouncements, markSeen } = useClientNotifications();
const visible = stickyAnnouncements.slice(0, MAX_VISIBLE_STICKY);
if (visible.length === 0) return null;
return (
<div className="w-full flex flex-col">
{visible.map((announcement) => (
<StickyAnnouncementRow
key={announcement.notification_id}
announcement={announcement}
onDismiss={() => markSeen(announcement.notification_id)}
/>
))}
</div>
);
}
@@ -0,0 +1,104 @@
import { Filter, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { BOOLEAN_FIELD_LABELS } from "@/utils/table.util";
// Resolves a filter's raw value(s) into what should actually be shown on the
// pill. Handles every shape ColumnFilter/FilterSheet can produce:
// - date range: { from, to }
// - boolean columns: "true" / "false" (or an array of those)
// - id-backed columns (e.g. createdBy/updatedBy): raw ids — resolved via
// fieldOptions[field], the { value, label } picklist DataTable cached
// from the last time that column's filter sheet was opened
// - everything else (free text, plain enum values): shown as-is
function resolveFilterDisplay(filter, fieldOptions) {
const raw = filter.value;
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
const { from, to } = raw;
if (from && to) return `${from} – ${to}`;
if (from) return `From ${from}`;
if (to) return `Until ${to}`;
return "";
}
const values = Array.isArray(raw) ? raw : [raw];
const boolLabels = BOOLEAN_FIELD_LABELS[filter.id];
const options = fieldOptions?.[filter.id];
const labels = values.map((v) => {
if (boolLabels) return boolLabels[v === "true" ? 1 : 0];
if (Array.isArray(options)) {
const match = options.find((o) => String(o?.value ?? o) === String(v));
if (match !== undefined) return match?.label ?? match?.value ?? match;
}
return v;
});
return labels.join(", ");
}
/**
* ActiveFilterPills
*
* Displays active column filters as dismissible pill badges.
*
* Props:
* @param {Array} filters - Array of { id, value } (TanStack columnFilters shape)
* @param {Array} attributes - Array of { field, name } for display labels
* @param {Object} [fieldOptions] - { [field]: [{ value, label }] } picklist cache,
* used to resolve id-backed filters (createdBy, etc.)
* @param {Function} onRemove - (id: string) => void — remove a single filter
* @param {Function} [onClearAll] - () => void — clear all filters
* @param {boolean} [showClearButton]- Render the "Clear filters (n)" button instead of pills
* (used in the toolbar area; omit for the pill row)
*/
export function ActiveFilterPills({
filters = [],
attributes = [],
fieldOptions = {},
onRemove,
onClearAll,
showClearButton = false,
}) {
if (filters.length === 0) return null;
// ── Toolbar variant: just a "Clear filters (n)" ghost button ──────────────
if (showClearButton) {
return (
<Button
variant="ghost"
size="sm"
onClick={onClearAll}
className="h-8 text-xs gap-1.5 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
Clear filters ({filters.length})
</Button>
);
}
// ── Pill row variant ───────────────────────────────────────────────────────
return (
<div className="flex items-center gap-2 flex-wrap mb-3">
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
{filters.map((f) => {
const attr = attributes.find((a) => a.field === f.id);
return (
<span
key={f.id}
className="inline-flex items-center gap-1 text-xs bg-primary/10 text-primary border border-primary/20 rounded-full px-2.5 py-0.5"
>
<span className="font-medium">{attr?.name ?? f.id}:</span>
{resolveFilterDisplay(f, fieldOptions)}
<button
onClick={() => onRemove(f.id)}
className="ml-0.5 hover:text-destructive transition-colors"
>
<X className="h-2.5 w-2.5" />
</button>
</span>
);
})}
</div>
);
}
@@ -0,0 +1,97 @@
import { flexRender } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import {
ArrowUpDown,
ArrowUp,
ArrowDown,
ChevronDown,
X,
} from "lucide-react";
/**
* ColumnActionsDropdown
*
* Per-column header button that opens a dropdown with sort and filter actions.
*
* Props:
* @param {Object} header - TanStack header object
* @param {Object} [attr] - Attribute definition (from column meta.attr)
* @param {boolean} isOpen - Controlled open state
* @param {Function} onOpenChange - (isOpen: boolean) => void
* @param {Function} onFilterClick - (e) => void — triggered when "Filter By" is clicked
* @param {boolean} [showFilter] - Whether to render the Filter By option (default: !!attr)
*/
export function ColumnActionsDropdown({
header,
attr,
isOpen,
onOpenChange,
onFilterClick,
showFilter,
}) {
const column = header.column;
const sorted = column.getIsSorted();
const canFilter = showFilter ?? !!attr;
const canSort = column.getCanSort();
return (
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 px-3">
{sorted === "asc" && (
<ArrowUp className="size-3.5 text-primary" />
)}
{sorted === "desc" && (
<ArrowDown className="size-3.5 text-primary" />
)}
{!sorted && (
<ArrowUpDown className="size-3.5 text-muted-foreground" />
)}
{flexRender(column.columnDef.header, header.getContext())}
<ChevronDown className="ml-2 size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel className="text-xs">Column Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
{canSort && (
<>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUp className="mr-2 size-4" />
Sort Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDown className="mr-2 size-4" />
Sort Desc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.clearSorting()}>
<X className="mr-2 size-4" />
Clear Sort
</DropdownMenuItem>
</>
)}
{canFilter && (
<>
{canSort && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onFilterClick}>
Filter By
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,58 @@
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { Columns2 } from "lucide-react";
export function ColumnVisibilityToggle({ table, label = "Columns" }) {
const columns = table.getAllLeafColumns().filter(
(col) => col.id !== "select" && col.id !== "actions"
);
const allVisible = columns.every((col) => col.getIsVisible());
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 text-xs gap-1.5">
<Columns2 className="h-3.5 w-3.5" />
{label}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Toggle visible columns
</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* ─── Show All ──────────────────────────────────────────────────── */}
<DropdownMenuCheckboxItem
checked={allVisible}
onCheckedChange={(v) => table.toggleAllColumnsVisible(v)}
className="text-xs font-medium"
>
Show All
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{/* ─── Individual columns ────────────────────────────────────────── */}
{columns.map((col) => {
const headerLabel = typeof col.columnDef.header === "string"
? col.columnDef.header
: col.id;
return (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onCheckedChange={(v) => col.toggleVisibility(v)}
className="text-xs"
>
{headerLabel}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,368 @@
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { useReactTable, getCoreRowModel, flexRender, } from "@tanstack/react-table";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table";
import { Spinner } from "@/components/ui/spinner";
import { pageSizes } from "@/utils/table.util";
import { ActiveFilterPills } from "./ActiveFilterPills";
import { ColumnActionsDropdown } from "./ColumnActionsDropdown";
import { TablePagination } from "./TablePagination";
import { ColumnVisibilityToggle } from "./ColumnVisibilityToggle";
import { ToolbarActions } from "./ToolbarActions";
import { SelectionToolbar } from "./SelectionToolbar";
import { cn } from "@/lib/utils";
export default function DataTable({
data,
columns,
attributes = [],
pagination,
setPagination,
loading,
onFetch,
onFetchFilterData,
onRefsReady,
renderFilterSheet,
toolbarActions = [],
selectionActions = [],
showColumnToggle = true,
title = "Records",
emptyMessage = "No records match the current filters.",
recordLabel = "record",
pageSizeOptions = pageSizes,
columnPinning = { right: [], left: [] },
className = "",
enableRowSelection = true,
}) {
// ─── Refs — always hold latest filters and sort ───────────────────────────
const filtersRef = useRef([]);
const sortRef = useRef([]);
// ─── State ────────────────────────────────────────────────────────────────
const [activeColumn, setActiveColumn] = useState(null);
const [sorting, setSorting] = useState([]);
const [columnFilters, setColumnFilters] = useState([]);
const [columnVisibility, setColumnVisibility] = useState({});
const [rowSelection, setRowSelection] = useState({});
const [filterState, setFilterState] = useState({
open: false, column: null, attr: null, data: [],
});
// Caches the { value, label } picklist fetched per field (e.g. createdBy's
// [{value: 56, label: "Obsequio, Russell..."}]) so ActiveFilterPills can
// show a name instead of a raw id once a filter is applied — the sheet
// itself only holds this data while open.
const [fieldOptions, setFieldOptions] = useState({});
const activeFilters = columnFilters.filter((f) => f.value !== "");
// ─── setFilters — called externally by dashboard charts ──────────────────
// Merges incoming filters with existing ones (replaces by id, appends new).
// Pass an empty array [] to clear all filters.
const setFilters = useCallback((incomingFilters) => {
setColumnFilters((prev) => {
let next;
if (!incomingFilters.length) {
next = [];
} else {
// Replace matching ids, keep the rest
const incomingIds = new Set(incomingFilters.map((f) => f.id));
const kept = prev.filter((f) => !incomingIds.has(f.id));
next = [...kept, ...incomingFilters];
}
const newFilters = next.filter((f) => f.value !== "");
filtersRef.current = newFilters;
onFetch({
page: 1,
limit: pagination.limit,
filters: newFilters,
sort: sortRef.current,
});
return next;
});
}, [onFetch, pagination.limit]);
// ─── Expose refs to parent ────────────────────────────────────────────────
useEffect(() => {
onRefsReady?.({
getFilters: () => filtersRef.current,
getSort: () => sortRef.current,
resetSelection: () => table.resetRowSelection(),
setFilters,
tableInstance: table,
});
onFetch({ page: 1, limit: pagination.limit, filters: [], sort: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setFilters]);
const handleOpenFilterSheet = async (e, column, attr) => {
e.preventDefault();
setActiveColumn(null);
// Enum/boolean columns already carry their full value set in
// attr.options.choices (see FilterSheet.jsx's sourceData) — hitting
// the field-values endpoint for them is a wasted round-trip.
if (attr?.type === "enum") {
setFilterState({ open: true, column, attr, data: [] });
return;
}
const data = await onFetchFilterData(attr.field);
setFilterState({ open: true, column, attr, data });
setFieldOptions((prev) => ({ ...prev, [attr.field]: data }));
};
const table = useReactTable({
data,
columns,
initialState: { columnPinning },
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination: {
pageIndex: pagination.page - 1,
pageSize: pagination.limit,
},
},
enableRowSelection,
onRowSelectionChange: setRowSelection,
manualPagination: true,
manualSorting: true,
manualFiltering: true,
pageCount: pagination.totalPages,
// ─── Sort change ──────────────────────────────────────────────────────
onSortingChange: (updater) => {
const next = typeof updater === "function" ? updater(sorting) : updater;
const newSort = next.length ? [{ id: next[0].id, desc: next[0].desc }] : [];
setSorting(next);
sortRef.current = newSort;
onFetch({
page: 1,
limit: pagination.limit,
filters: filtersRef.current,
sort: newSort,
});
},
// ─── Filter change ────────────────────────────────────────────────────
onColumnFiltersChange: (updater) => {
const next = typeof updater === "function" ? updater(columnFilters) : updater;
const newFilters = next.filter((f) => f.value !== "");
setColumnFilters(next);
filtersRef.current = newFilters;
onFetch({
page: 1,
limit: pagination.limit,
filters: newFilters,
sort: sortRef.current,
});
},
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
});
// ─── Page change ──────────────────────────────────────────────────────────
const handlePageChange = (page) => {
setPagination((p) => ({ ...p, page }));
onFetch({
page,
limit: pagination.limit,
filters: filtersRef.current,
sort: sortRef.current,
});
};
const selectedRows = useMemo(
() => table.getSelectedRowModel().rows.map((r) => r.original),
// eslint-disable-next-line react-hooks/exhaustive-deps
[rowSelection, data]
);
const hasSelection = selectedRows.length > 0;
return (
<div className={`w-full bg-card rounded-lg border border-border mb-6 px-6 py-4 relative ${className}`}>
{/* ── Toolbar ── */}
{hasSelection ? (
<SelectionToolbar
selectedRows={selectedRows}
onClearSelection={() => table.resetRowSelection()}
selectionActions={selectionActions}
recordLabel={recordLabel}
/>
) : (
<div className="flex items-start justify-between gap-4 mb-5">
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
<div className="flex items-center gap-2 shrink-0">
<ActiveFilterPills
filters={activeFilters}
attributes={attributes}
fieldOptions={fieldOptions}
onClearAll={() => {
setColumnFilters([]);
filtersRef.current = [];
onFetch({ page: 1, limit: pagination.limit, filters: [], sort: sortRef.current });
}}
onRemove={(id) => {
const next = columnFilters.filter((c) => c.id !== id);
const newFilters = next.filter((f) => f.value !== "");
setColumnFilters(next);
filtersRef.current = newFilters;
onFetch({ page: 1, limit: pagination.limit, filters: newFilters, sort: sortRef.current });
}}
showClearButton
/>
<ToolbarActions actions={toolbarActions} />
{showColumnToggle && <ColumnVisibilityToggle table={table} />}
</div>
</div>
)}
<ActiveFilterPills
filters={activeFilters}
attributes={attributes}
fieldOptions={fieldOptions}
onRemove={(id) => {
const next = columnFilters.filter((c) => c.id !== id);
const newFilters = next.filter((f) => f.value !== "");
setColumnFilters(next);
filtersRef.current = newFilters;
onFetch({ page: 1, limit: pagination.limit, filters: newFilters, sort: sortRef.current });
}}
/>
{/* ── Table ── */}
<div className="rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id} className="bg-muted/40 hover:bg-muted/40">
{hg.headers.map((header) => {
const isPinned = header.column.getIsPinned();
const attr = header.column.columnDef.meta?.attr;
const isPlain = header.column.id === "select" || header.column.id === "actions";
return (
<TableHead
key={header.id}
className={cn("align-middle whitespace-nowrap", isPinned ? "bg-muted" : "")}
style={{
position: isPinned ? "sticky" : "relative",
right: isPinned === "right" ? header.column.getStart("right") : undefined,
left: isPinned === "left" ? header.column.getStart("left") : undefined,
zIndex: isPinned ? 2 : 0,
width: header.column.columnDef.size,
}}
>
{isPlain ? (
flexRender(header.column.columnDef.header, header.getContext())
) : (
<ColumnActionsDropdown
header={header}
attr={attr}
isOpen={activeColumn === header.column.id}
onOpenChange={(isOpen) => setActiveColumn(isOpen ? header.column.id : null)}
onFilterClick={(e) => handleOpenFilterSheet(e, header.column, attr)}
showFilter={attr?.filterable}
/>
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
{renderFilterSheet?.({
open: filterState.open,
onOpenChange: (v) => setFilterState((p) => ({ ...p, open: v })),
column: filterState.column,
attr: filterState.attr,
data: filterState.data,
loading,
})}
<TableBody>
{table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="text-center py-16 text-muted-foreground text-sm"
>
{emptyMessage}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? "selected" : undefined}
className="hover:bg-muted/30 transition-colors data-[state=selected]:bg-primary/5"
>
{row.getVisibleCells().map((cell) => {
const isPinned = cell.column.getIsPinned();
return (
<TableCell
key={cell.id}
className={cn("m-0", isPinned && "bg-card")}
style={{
position: isPinned ? "sticky" : "relative",
right: isPinned === "right" ? 0 : undefined,
left: isPinned === "left" ? cell.column.getStart("left") : undefined,
zIndex: isPinned ? 1 : 0,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm z-10">
<Spinner className="size-8" />
</div>
)}
</div>
{/* ── Footer ── */}
<TablePagination
table={table}
pagination={pagination}
onPageChange={handlePageChange}
totalRecords={pagination.totalRecords}
rowCount={data.length}
onPageSizeChange={(size) => {
setPagination((p) => ({ ...p, limit: size, page: 1 }));
onFetch({
page: 1,
limit: size,
filters: filtersRef.current,
sort: sortRef.current,
});
}}
pageSizeOptions={pageSizeOptions}
recordLabel={recordLabel}
/>
</div>
);
}
@@ -0,0 +1,103 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { MoreHorizontal } from "lucide-react";
/**
* RowActions
*
* Renders a single kebab (⋯) dropdown per row containing all defined actions.
* Each action is evaluated against the row's data for conditional
* visibility and disabled state.
*
* ─── Action definition shape ─────────────────────────────────────────────────
*
* @field {string} key Unique identifier
* @field {string} label Menu item text
* @field {ReactNode} [icon] Optional leading icon
* @field {Function} onClick (rowData) => void
* @field {Function|boolean} [hidden] (rowData) => boolean — hide for specific rows
* @field {Function|boolean} [disabled](rowData) => boolean — disable for specific rows
* @field {boolean} [separator] Render a separator BEFORE this item
* @field {string} [className] Extra classes on the menu item
*
* ─────────────────────────────────────────────────────────────────────────────
*
* @param {Object} row TanStack row object (row.original = raw data)
* @param {Array} rowActions Array of action definitions
* @param {string} [dropdownLabel] Label shown at top of the menu (default: "Actions")
* @param {string} [align] Dropdown alignment ("end" | "start", default: "end")
*/
export function RowActions({
row,
rowActions = [],
dropdownLabel = "Actions",
align = "end",
}) {
const data = row.original;
const visibleActions = rowActions.filter((a) => !resolveFlag(a.hidden, data));
if (!visibleActions.length) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 data-[state=open]:bg-muted"
aria-label="Open actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-44">
{dropdownLabel && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{dropdownLabel}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{visibleActions.map((action) => {
const isDisabled = resolveFlag(action.disabled, data);
return (
<span key={action.key}>
{action.separator && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => action.onClick(data)}
disabled={isDisabled}
className={action.className}
>
{action.icon && (
<span className="mr-2 flex items-center size-4">
{action.icon}
</span>
)}
{action.label}
</DropdownMenuItem>
</span>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
// ── Helper ────────────────────────────────────────────────────────────────────
/** Resolves a flag that is either a static boolean or a (rowData) => boolean fn */
function resolveFlag(flag, data) {
if (typeof flag === "function") return flag(data);
return flag ?? false;
}
@@ -0,0 +1,91 @@
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
/**
* SelectionToolbar
*
* Replaces the normal toolbar area when one or more rows are selected.
* Shows a selection count and flat inline action buttons — no dropdowns,
* since the actions are already visible.
*
* ─── Selection action definition shape ───────────────────────────────────────
*
* @field {string} key Unique identifier
* @field {string} label Button text
* @field {ReactNode} [icon] Optional leading icon
* @field {Function} onClick (selectedRows: rowData[], table: TableInstance) => void
* Receives selected row data AND the TanStack table instance
* so handlers like export can call table.getVisibleLeafColumns().
* @field {string} [variant] shadcn Button variant (default: "outline")
* @field {string} [className] Extra classes
* @field {boolean|Function} [disabled] Static boolean or (selectedRows) => boolean
* @field {boolean|Function} [hidden] Static boolean or (selectedRows) => boolean
*
* ─────────────────────────────────────────────────────────────────────────────
*
* @param {Array} selectedRows Array of raw row data objects
* @param {Function} onClearSelection () => void — clears all checkboxes
* @param {Array} selectionActions Action button definitions (see above)
* @param {string} [recordLabel] Singular label (default: "record")
* @param {TableInstance} table TanStack table instance forwarded from DataTable.
* Passed as the second argument to every onClick handler.
*/
export function SelectionToolbar({
selectedRows = [],
onClearSelection,
selectionActions = [],
recordLabel = "record",
table,
}) {
if (!selectedRows.length) return null;
const count = selectedRows.length;
const label = count === 1 ? recordLabel : `${recordLabel}s`;
return (
<div className="flex items-center justify-between gap-4 mb-5">
{/* Left: count + clear */}
<div className="flex items-center gap-2">
<button
onClick={onClearSelection}
className="flex items-center justify-center h-5 w-5 rounded-sm border border-primary bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
aria-label="Clear selection"
>
<X className="h-3 w-3" />
</button>
<span className="text-sm font-medium">
{count} {label} selected
</span>
</div>
{/* Right: flat action buttons */}
<div className="flex items-center gap-2 shrink-0">
{selectionActions
.filter((a) => !resolveFlag(a.hidden, selectedRows))
.map((action) => {
const isDisabled = resolveFlag(action.disabled, selectedRows);
return (
<Button
key={action.key}
variant={action.variant ?? "outline"}
size="sm"
className={`h-8 text-xs gap-1.5 ${action.className ?? ""}`}
onClick={() => action.onClick(selectedRows, table)}
disabled={isDisabled}
>
{action.icon}
{action.label}
</Button>
);
})}
</div>
</div>
);
}
// ── Helper ────────────────────────────────────────────────────────────────────
function resolveFlag(flag, data) {
if (typeof flag === "function") return flag(data);
return flag ?? false;
}
@@ -0,0 +1,119 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ChevronsLeft, ChevronsRight } from "lucide-react";
import { pageSizes } from "@/utils/table.util";
/**
* TablePagination
*
* Footer bar with First / Previous / Next / Last page controls,
* a "Page X of Y" indicator, a row count summary, and a rows-per-page picker.
*
* Props:
* @param {Object} pagination - { page, limit, totalPages, totalRecords, hasPrevPage, hasNextPage }
* @param {Function} onPageChange - (updater | patch) => void
* @param {number} rowCount - Number of rows currently visible (e.g. users.length)
* @param {number} [totalRecords] - Grand total record count (falls back to pagination.totalRecords)
* @param {Function} [onPageSizeChange] - (size: number) => void — called when rows-per-page changes
* @param {number[]} [pageSizeOptions] - Options for rows-per-page picker (default: [10, 50, 100])
* @param {string} [recordLabel] - Singular label for records (default: "record")
* @param {Object} [table] - TanStack table instance (used to read current pageSize)
*/
export function TablePagination({
pagination,
onPageChange,
rowCount,
totalRecords,
onPageSizeChange,
pageSizeOptions = pageSizes,
recordLabel = "record",
table,
}) {
const total = totalRecords ?? pagination.totalRecords;
const currentPageSize = pagination.limit;
const pluralLabel = rowCount === 1 ? recordLabel : `${recordLabel}s`;
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 px-4 py-4 border-t border-border">
<div className="text-sm text-muted-foreground">
Showing {rowCount} of {total} {pluralLabel}
</div>
<div className="flex flex-wrap items-center gap-2 justify-end">
{/* First */}
<Button
variant="outline"
onClick={() => onPageChange(1)}
disabled={!pagination.hasPrevPage}
>
<ChevronsLeft className="size-4" /> First
</Button>
{/* Previous */}
<Button
variant="outline"
onClick={() => onPageChange(pagination.page - 1)}
disabled={!pagination.hasPrevPage}
>
Previous
</Button>
<span className="text-sm font-medium px-2">
Page {pagination.page} of {pagination.totalPages}
</span>
{/* Next */}
<Button
variant="outline"
onClick={() => onPageChange(pagination.page + 1)}
disabled={!pagination.hasNextPage}
>
Next
</Button>
{/* Last */}
<Button
variant="outline"
onClick={() => onPageChange(pagination.totalPages)}
disabled={!pagination.hasNextPage}
>
Last <ChevronsRight className="size-4" />
</Button>
{/* Rows per page */}
{onPageSizeChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="font-medium text-sm">
Rows per page: {currentPageSize}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Select rows</DropdownMenuLabel>
<DropdownMenuSeparator />
{pageSizeOptions.map((size) => (
<DropdownMenuItem
key={size}
onClick={() => onPageSizeChange(size)}
className={
currentPageSize === size ? "bg-muted font-medium" : ""
}
>
Show {size}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
}
@@ -0,0 +1,153 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { ChevronDown } from "lucide-react";
/**
* ToolbarActions
*
* Renders a list of configurable toolbar buttons beside the column visibility toggle.
* Each button can be one of three types: "icon", "button", or "dropdown".
*
* ─── Button definition shape ───────────────────────────────────────────────────
*
* Common fields (all types):
* @field {string} key Unique identifier
* @field {"icon"|"button"|"dropdown"} type Render mode
* @field {ReactNode} icon Lucide icon or any ReactNode
* @field {string} [label] Text label (required for "button"; used as tooltip for "icon")
* @field {string} [variant] shadcn Button variant (default: "outline")
* @field {string} [size] shadcn Button size (default: "sm")
* @field {string} [className] Extra classes on the button
* @field {boolean} [disabled] Disable the button
* @field {boolean} [hidden] Completely hide the button
*
* For type "icon" | "button":
* @field {Function} onClick (table: TableInstance) => void
* Receives the TanStack table instance so handlers
* like export can call table.getVisibleLeafColumns().
*
* For type "dropdown":
* @field {string} [dropdownLabel] Optional label shown at the top of the dropdown
* @field {Array} items Dropdown item definitions:
* @field {string} key Unique key
* @field {ReactNode} icon Item icon
* @field {string} label Item label
* @field {Function} onClick (table: TableInstance) => void
* @field {boolean} [disabled] Disable this item
* @field {boolean} [hidden] Hide this item
* @field {boolean} [separator] Render a separator BEFORE this item
* @field {string} [className] Extra classes on the item
*
* ───────────────────────────────────────────────────────────────────────────────
*
* @param {Array} actions Array of button definitions (see above)
* @param {string} [align] DropdownMenuContent align ("end" | "start", default: "end")
* @param {TableInstance} table TanStack table instance forwarded from DataTable.
* Passed as the first argument to every onClick handler.
*/
export function ToolbarActions({ actions = [], align = "end", table }) {
if (!actions.length) return null;
return (
<>
{actions
.filter((action) => !action.hidden)
.map((action) => {
// ── Icon-only button ─────────────────────────────────────────────
if (action.type === "icon") {
return (
<Button
key={action.key}
variant={action.variant ?? "outline"}
size={action.size ?? "sm"}
className={`h-8 w-8 p-0 ${action.className ?? ""}`}
onClick={() => action.onClick(table)}
disabled={action.disabled}
title={action.label}
aria-label={action.label}
>
{action.icon}
</Button>
);
}
// ── Icon + label button ──────────────────────────────────────────
if (action.type === "button") {
return (
<Button
key={action.key}
variant={action.variant ?? "outline"}
size={action.size ?? "sm"}
className={`h-8 text-xs gap-1.5 ${action.className ?? ""}`}
onClick={() => action.onClick(table)}
disabled={action.disabled}
>
{action.icon}
{action.label}
</Button>
);
}
// ── Dropdown button ──────────────────────────────────────────────
if (action.type === "dropdown") {
const visibleItems = (action.items ?? []).filter((i) => !i.hidden);
return (
<DropdownMenu key={action.key}>
<DropdownMenuTrigger asChild>
<Button
variant={action.variant ?? "outline"}
size={action.size ?? "sm"}
className={`h-8 text-xs gap-1.5 ${action.className ?? ""}`}
disabled={action.disabled}
>
{action.icon}
{action.label}
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-48">
{action.dropdownLabel && (
<>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{action.dropdownLabel}
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{visibleItems.map((item) => (
<span key={item.key}>
{item.separator && <DropdownMenuSeparator />}
<DropdownMenuItem
onClick={() => item.onClick(table)}
disabled={item.disabled}
className={item.className}
>
{item.icon && (
<span className="mr-2 flex items-center">
{item.icon}
</span>
)}
{item.label}
</DropdownMenuItem>
</span>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
return null;
})}
</>
);
}
@@ -0,0 +1,88 @@
import { RowActions } from "@/components/generic/Table/RowActions";
/**
* buildRowActionsColumn
*
* Factory that returns a TanStack column definition for the kebab actions column.
* Append the result to your `columns` array — no other setup needed.
*
* @param {Array} rowActions Action definitions (see RowActions for shape)
* @param {Object} [options]
* @param {string} [options.id] Column id (default: "actions")
* @param {string} [options.header] Column header text (default: "")
* @param {string} [options.dropdownLabel] Kebab menu label (default: "Actions")
* @param {string} [options.align] Dropdown alignment (default: "end")
* @param {number} [options.size] Column px width (default: 48)
* @returns {Object} TanStack ColumnDef
*
* ─── Usage ───────────────────────────────────────────────────────────────────
*
* import { buildRowActionsColumn } from "@/components/generic/buildRowActionsColumn";
* import { Pencil, Trash2, Eye, Archive } from "lucide-react";
*
* const columns = [
* ...buildColumns(attributes),
*
* buildRowActionsColumn(
* [
* {
* key: "view",
* label: "View details",
* icon: <Eye className="h-3.5 w-3.5" />,
* onClick: (row) => navigate(`/users/${row.id}`),
* },
* {
* key: "edit",
* label: "Edit",
* icon: <Pencil className="h-3.5 w-3.5" />,
* onClick: (row) => navigate(`/users/${row.id}/edit`),
* disabled: (row) => row.role === "super_admin",
* },
* {
* key: "archive",
* label: "Archive",
* icon: <Archive className="h-3.5 w-3.5" />,
* onClick: (row) => archiveUser(row.id),
* hidden: (row) => row.status === "archived",
* separator: true,
* },
* {
* key: "delete",
* label: "Delete",
* icon: <Trash2 className="h-3.5 w-3.5" />,
* className: "text-destructive focus:text-destructive",
* onClick: (row) => confirmDelete(row.id),
* disabled: (row) => row.role === "admin",
* },
* ],
* { dropdownLabel: "User Actions" }
* ),
* ];
*
* ─────────────────────────────────────────────────────────────────────────────
*/
export function buildRowActionsColumn(rowActions, options = {}) {
const {
id = "actions",
header = "Actions",
dropdownLabel = "Actions",
align = "end",
size = 48,
} = options;
return {
id,
header,
size,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => (
<RowActions
row={row}
rowActions={rowActions}
dropdownLabel={dropdownLabel}
align={align}
/>
),
};
}
@@ -0,0 +1,70 @@
import { Checkbox } from "@/components/ui/checkbox";
/**
* buildSelectionColumn
*
* Returns a TanStack column definition for the checkbox selection column.
* Prepend this to your `columns` array.
*
* The header renders a "select all on this page" checkbox with an
* indeterminate state when only some rows are checked.
* Each cell renders a per-row checkbox, disabled for any row TanStack
* reports as non-selectable via `row.getCanSelect()` — controlled by
* passing a function to DataTable's `enableRowSelection` prop, e.g.
* `enableRowSelection={(row) => row.original.user_id !== currentUserId}`
* to prevent a user from selecting their own row.
*
* @returns {Object} TanStack ColumnDef
*
* ─── Usage ───────────────────────────────────────────────────────────────────
*
* import { buildSelectionColumn } from "@/components/generic/buildSelectionColumn";
*
* const columns = [
* buildSelectionColumn(),
* ...buildColumns(attributes),
* buildRowActionsColumn(rowActions),
* ];
*
* ─────────────────────────────────────────────────────────────────────────────
*/
export function buildSelectionColumn() {
return {
id: "select",
size: 40,
enableSorting: false,
enableHiding: false,
header: ({ table }) => (
<div className="flex items-center justify-center px-1">
<Checkbox
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all rows on this page"
/>
</div>
),
cell: ({ row }) => {
const canSelect = row.getCanSelect();
return (
<div className="flex items-center justify-center px-1">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
disabled={!canSelect}
aria-label={canSelect ? "Select row" : "Row cannot be selected"}
/>
</div>
);
},
};
}
@@ -0,0 +1,284 @@
/***********************************************************************************************************************************************************************
* File Name : TaskMultiSelect.jsx
* Type : Reusable Component
* Description : Searchable multi-select dropdown for sibling Tasks (used to pick
* a task's prerequisite tasks). Cloned from GroupMultiSelect.jsx's
* UI/UX — portal dropdown, badge + "+N" overflow, select all/clear —
* but takes `tasks` directly from the caller instead of fetching its
* own endpoint, since the parent page already has the sibling task
* list loaded.
*
* Props:
* value : string[] — selected task_ids
* onChange : (ids: string[]) => void
* tasks : { task_id, name }[] — candidate tasks (already excludes self)
* disabled? : boolean
* placeholder?: string
***********************************************************************************************************************************************************************/
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { Check, ChevronsUpDown, X, ListChecks, ChevronLeft, ChevronRight } from 'lucide-react';
const PAGE_SIZE = 5;
export default function TaskMultiSelect({
value = [],
onChange,
tasks = [],
disabled = false,
placeholder = 'Select tasks…',
}) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [dropdownStyle, setDropdownStyle] = useState({});
const triggerRef = useRef(null);
const dropdownRef = useRef(null);
// ── Position portal dropdown under trigger ────────────────────────────────
useLayoutEffect(() => {
if (!open || !triggerRef.current) return;
const reposition = () => {
const rect = triggerRef.current.getBoundingClientRect();
setDropdownStyle({
position: 'fixed',
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
zIndex: 9999,
});
};
reposition();
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
return () => {
window.removeEventListener('scroll', reposition, true);
window.removeEventListener('resize', reposition);
};
}, [open]);
// ── Close on outside click ────────────────────────────────────────────────
useEffect(() => {
if (!open) return;
const handler = (e) => {
if (
triggerRef.current?.contains(e.target) ||
dropdownRef.current?.contains(e.target)
) return;
setOpen(false);
setSearch('');
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [open]);
// ── Helpers ───────────────────────────────────────────────────────────────
const filtered = tasks.filter((t) =>
t.name.toLowerCase().includes(search.toLowerCase())
);
const selectedTasks = tasks.filter((t) => value.includes(t.task_id));
const overflowCount = selectedTasks.length - 1;
// All currently visible (filtered) IDs — used for select-all scope
const filteredIds = filtered.map((t) => t.task_id);
const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id));
// ── Pagination ────────────────────────────────────────────────────────────
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
const paginated = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
// Reset to page 1 whenever the search term changes
const handleSearchChange = (e) => {
setSearch(e.target.value);
setPage(1);
};
const toggle = (taskId) =>
onChange(value.includes(taskId)
? value.filter((id) => id !== taskId)
: [...value, taskId]
);
const remove = (e, taskId) => {
e.stopPropagation();
onChange(value.filter((id) => id !== taskId));
};
// Select all visible (filtered) tasks
const handleSelectAll = () => {
const merged = Array.from(new Set([...value, ...filteredIds]));
onChange(merged);
};
// Clear all selections
const handleClear = () => onChange([]);
// ── Portal dropdown ───────────────────────────────────────────────────────
const dropdown = open && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="rounded-md border border-border bg-popover shadow-lg"
>
{/* Search row */}
<div className="p-2 border-b border-border">
<Input
autoFocus
value={search}
onChange={handleSearchChange}
placeholder="Search tasks…"
className="h-8 text-sm"
/>
</div>
{/* Select all / Clear row */}
{tasks.length > 0 && (
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/40">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={allFilteredSelected ? handleClear : handleSelectAll}
className="text-xs text-primary hover:underline underline-offset-2 font-medium"
>
{allFilteredSelected ? 'Deselect all' : 'Select all'}
{search && ` (${filteredIds.length})`}
</button>
{value.length > 0 && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
>
Clear ({value.length})
</button>
)}
</div>
)}
{/* Options */}
<ul className="py-1">
{filtered.length === 0 ? (
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
No tasks found.
</li>
) : (
paginated.map((t) => {
const selected = value.includes(t.task_id);
return (
<li
key={t.task_id}
onMouseDown={(e) => e.preventDefault()}
onClick={() => toggle(t.task_id)}
className={cn(
'flex items-center gap-2 px-3 py-2 text-sm cursor-pointer select-none',
'hover:bg-accent hover:text-accent-foreground',
selected && 'bg-accent/50'
)}
>
<div className={cn(
'h-4 w-4 rounded border flex items-center justify-center shrink-0',
selected
? 'bg-primary border-primary text-primary-foreground'
: 'border-input'
)}>
{selected && <Check className="h-3 w-3" />}
</div>
<ListChecks className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{t.name}</span>
</li>
);
})
)}
</ul>
{/* Pagination */}
{filtered.length > PAGE_SIZE && (
<div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-muted/40">
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={safePage <= 1}
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
>
<ChevronLeft className="h-3.5 w-3.5" />
Prev
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{safePage} / {totalPages}
</span>
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={safePage >= totalPages}
className="flex items-center gap-0.5 text-xs text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none"
>
Next
<ChevronRight className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>,
document.body
);
return (
<>
{/* ── Trigger button ───────────────────────────────────────────── */}
<button
ref={triggerRef}
type="button"
disabled={disabled}
onClick={() => { setOpen((o) => !o); setSearch(''); setPage(1); }}
className={cn(
'w-full min-h-9 px-3 py-1.5 rounded-md border border-input bg-background text-sm',
'flex items-center gap-1.5 text-left',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
'disabled:opacity-50 disabled:cursor-not-allowed',
open && 'ring-2 ring-ring ring-offset-1'
)}
>
{selectedTasks.length === 0 ? (
<span className="text-muted-foreground flex-1">{placeholder}</span>
) : (
<span className="flex items-center gap-1 flex-1 min-w-0">
{/* Always show only the first selected task */}
<Badge variant="secondary" className="gap-1 text-xs pr-1 shrink-0 max-w-[180px]">
<ListChecks className="h-3 w-3 shrink-0" />
<span className="truncate">{selectedTasks[0].name}</span>
<span
role="button"
tabIndex={0}
onClick={(e) => remove(e, selectedTasks[0].task_id)}
onKeyDown={(e) => e.key === 'Enter' && remove(e, selectedTasks[0].task_id)}
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5 cursor-pointer shrink-0"
>
<X className="h-2.5 w-2.5" />
</span>
</Badge>
{/* +N overflow chip */}
{overflowCount > 0 && (
<Badge variant="outline" className="text-xs px-1.5 shrink-0">
+{overflowCount}
</Badge>
)}
</span>
)}
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground shrink-0 ml-auto" />
</button>
{/* ── Portalled dropdown ────────────────────────────────────────── */}
{dropdown}
</>
);
}
@@ -0,0 +1,22 @@
// components/generic/TranscodeStatusBanner.jsx
//
// Small inline notice for a video asset's background remux (see backend
// services/assetTranscode.service.js) — .mov/.mkv uploads get repackaged
// into a faststart .mp4 for fast in-browser playback. Non-blocking: the
// asset still plays from its original (slower) file while this is pending/
// processing, this banner is just a heads-up. Renders nothing once the
// asset is "done"/"none" (fast already) — "failed" also renders nothing,
// the asset just quietly keeps playing from the original.
import { Loader2 } from "lucide-react";
export function TranscodeStatusBanner({ status }) {
if (status !== "pending" && status !== "processing") return null;
return (
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground bg-muted/40 border-b">
<Loader2 className="size-3.5 animate-spin" />
Optimizing this video for faster playback — still watchable now, will load quicker shortly.
</div>
);
}
@@ -0,0 +1,109 @@
// components/generic/UploadProgressToast.jsx
//
// Floating widget mounted once in AdminLayout (outside the router Outlet's
// unmount cycle) so it keeps showing Add Assets Bulk's upload progress no
// matter what admin page you navigate to mid-batch. State comes from
// UploadQueueContext, which lives above the router for the same reason.
//
// Only renders "bulk"-sourced jobs — Add File (single) uploads share the
// same underlying queue (so they too survive navigation) but get their own
// SingleUploadToast instead of being folded into this multi-file "N of M"
// widget, which was designed around an actual batch and reads oddly for a
// single file.
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ChevronUp, ChevronDown, X, Loader2, CheckCircle2, XCircle } from "lucide-react";
import { useUploadQueue } from "@/contexts/UploadQueueContext";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
const ACTIVE = new Set(["uploading", "queued"]);
const FAILED = new Set(["failed", "invalid"]);
export default function UploadProgressToast() {
const { jobs: allJobs, retryJob } = useUploadQueue();
const jobs = allJobs.filter((j) => j.source === "bulk");
const navigate = useNavigate();
const [dismissed, setDismissed] = useState(false);
const [expanded, setExpanded] = useState(false);
const prevActiveCountRef = useRef(0);
const active = jobs.filter((j) => ACTIVE.has(j.status));
const finished = jobs.filter((j) => !ACTIVE.has(j.status));
// A fresh batch starting up should always resurface the widget, even if
// the previous one was dismissed.
useEffect(() => {
if (prevActiveCountRef.current === 0 && active.length > 0) setDismissed(false);
prevActiveCountRef.current = active.length;
}, [active.length]);
if (dismissed || jobs.length === 0) return null;
const aggregatePct = active.length
? Math.round(active.reduce((sum, j) => sum + j.progress, 0) / active.length)
: 100;
const failedCount = finished.filter((j) => FAILED.has(j.status)).length;
const label = active.length
? `Uploading ${active.length} file${active.length === 1 ? "" : "s"}… ${aggregatePct}%`
: failedCount
? `${finished.length - failedCount} of ${finished.length} file${finished.length === 1 ? "" : "s"} uploaded`
: `${finished.length} file${finished.length === 1 ? "" : "s"} uploaded`;
return (
<div className="fixed bottom-4 right-4 z-[60] w-80 rounded-lg border bg-card shadow-lg overflow-hidden">
<div
className="flex items-center gap-2 px-3 py-2.5 cursor-pointer select-none"
onClick={() => setExpanded((v) => !v)}
>
{active.length > 0
? <Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
: failedCount
? <XCircle className="h-4 w-4 text-destructive shrink-0" />
: <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />}
<span className="text-sm font-medium flex-1 truncate">{label}</span>
<Button type="button" variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" />}
</Button>
<Button
type="button" variant="ghost" size="icon" className="h-6 w-6"
disabled={active.length > 0}
onClick={(e) => { e.stopPropagation(); setDismissed(true); }}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{active.length > 0 && <Progress value={aggregatePct} className="h-1 rounded-none" />}
{expanded && (
<div className="max-h-64 overflow-y-auto border-t divide-y">
{jobs.map((job) => (
<div key={job.id} className="flex items-center gap-2 px-3 py-2 text-xs">
{job.status === "uploaded" && <CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />}
{FAILED.has(job.status) && <XCircle className="h-3.5 w-3.5 text-destructive shrink-0" />}
{ACTIVE.has(job.status) && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground shrink-0" />}
<span className="flex-1 truncate">{job.name}</span>
{job.status === "failed" && (
<button type="button" className="text-primary hover:underline shrink-0" onClick={() => retryJob(job.id)}>
Retry
</button>
)}
</div>
))}
<button
type="button"
className="w-full text-xs text-primary hover:underline py-2"
onClick={() => navigate("/admin/assets/add/bulk")}
>
View upload details
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,263 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { useTheme } from '@/contexts/ThemeContext'
import { useProfile } from '@/contexts/ProfileProvider'
import { useDateTimePreference } from '@/contexts/DateTimePreferenceContext'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Label } from '@/components/ui/label'
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, Sun, Moon, Monitor, Loader2, Check, Clock } from 'lucide-react'
import { AVATAR_COLORS } from '@/data/profile.data'
// ─── Theme Option ─────────────────────────────────────────────────────────────
function ThemeOption({ value, label, icon: Icon, active, onClick }) {
return (
<button
onClick={() => onClick(value)}
className={`relative flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all cursor-pointer w-full
${active
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted/40'
}`}
>
<Icon size={18} className={active ? 'text-primary' : 'text-muted-foreground'} />
<span className={`text-xs font-medium ${active ? 'text-primary' : 'text-muted-foreground'}`}>
{label}
</span>
{active && (
<span className="absolute top-2 right-2">
<Check size={12} className="text-primary" />
</span>
)}
</button>
)
}
// ─── Timezone Option ──────────────────────────────────────────────────────────
function TimezoneOption({ value, label, description, active, onClick }) {
return (
<button
onClick={() => onClick(value)}
className={`flex flex-col gap-1 p-3 rounded-lg border-2 transition-all cursor-pointer w-full text-left
${active
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted/40'
}`}
>
<span className={`text-sm font-semibold ${active ? 'text-primary' : 'text-foreground'}`}>
{label}
</span>
<span className="text-xs text-muted-foreground">{description}</span>
{active && <Check size={12} className="text-primary mt-0.5" />}
</button>
)
}
// ─── Settings Dialog ──────────────────────────────────────────────────────────
function SettingsDialog({ open, onClose }) {
const { theme, setTheme } = useTheme()
const { timezone, setTimezone } = useDateTimePreference()
const [tab, setTab] = useState('appearance')
const TABS = [
{ id: 'appearance', label: 'Appearance', icon: Sun },
{ id: 'datetime', label: 'Date & Time', icon: Clock },
]
return (
<Dialog open={open} onOpenChange={onClose}>
<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 px-4 py-3 border-b">
<span className="text-sm font-semibold">Settings</span>
</div>
<nav className="flex-1 p-2 space-y-0.5">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium transition-colors text-left
${tab === t.id
? 'bg-background text-foreground shadow-sm border border-border/50'
: 'text-muted-foreground hover:text-foreground hover:bg-background/60'
}`}
>
<t.icon size={14} />
{t.label}
</button>
))}
</nav>
</div>
{/* ── Content ── */}
<div className="flex-1 overflow-y-auto p-6">
{/* Appearance */}
{tab === 'appearance' && (
<div className="space-y-5">
<div>
<h2 className="text-base font-semibold">Appearance</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Choose how the interface looks for you.
</p>
</div>
<Separator />
<div>
<Label className="text-sm font-medium mb-3 block">Theme</Label>
<div className="grid grid-cols-3 gap-3 relative">
<ThemeOption value="light" label="Light" icon={Sun} active={theme === 'light'} onClick={setTheme} />
<ThemeOption value="dark" label="Dark" icon={Moon} active={theme === 'dark'} onClick={setTheme} />
<ThemeOption value="system" label="System" icon={Monitor} active={theme === 'system'} onClick={setTheme} />
</div>
</div>
</div>
)}
{/* Date & Time */}
{tab === 'datetime' && (
<div className="space-y-5">
<div>
<h2 className="text-base font-semibold">Date &amp; Time</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Choose how dates and times are displayed across the platform.
Your locale format is taken from your browser&apos;s language setting.
</p>
</div>
<Separator />
<div>
<Label className="text-sm font-medium mb-3 block">Timezone Display</Label>
<div className="grid grid-cols-2 gap-3">
<TimezoneOption
value="local"
label="Local"
description="Dates shown in your device's local timezone."
active={timezone === 'local'}
onClick={setTimezone}
/>
<TimezoneOption
value="UTC"
label="UTC"
description="Dates shown in Coordinated Universal Time (UTC+0)."
active={timezone === 'UTC'}
onClick={setTimezone}
/>
</div>
</div>
<div className="rounded-lg border bg-muted/40 p-3 text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Preview</p>
<p>Date: <span className="text-foreground">{new Date().toLocaleDateString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}</span></p>
<p>Date &amp; Time: <span className="text-foreground">{new Date().toLocaleString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}</span></p>
{timezone === 'UTC' && <p className="text-amber-600 dark:text-amber-400 font-medium">All times shown in UTC</p>}
</div>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}
// ─── Sign Out Overlay ─────────────────────────────────────────────────────────
function SignOutOverlay({ open }) {
return (
<AlertDialog open={open}>
<AlertDialogContent className="max-w-xs">
<div className="flex flex-col items-center justify-center gap-4 py-6">
<Loader2 className="size-8 animate-spin text-muted-foreground" />
<p className="text-sm font-medium">Signing out...</p>
</div>
</AlertDialogContent>
</AlertDialog>
)
}
// ─── Main UserMenu ────────────────────────────────────────────────────────────
export default function UserMenu() {
const { user, logout } = useAuth()
const { avatarUrl } = useProfile()
const navigate = useNavigate()
const [settingsOpen, setSettingsOpen] = useState(false)
const [signingOut, setSigningOut] = useState(false)
// Build initials from personal_info or fallback to acc_type first char
const given = user?.personal_info?.name?.given_name ?? ''
const last = user?.personal_info?.name?.last_name ?? ''
const initials = given && last
? (given[0] + last[0]).toUpperCase()
: (user?.email?.[0] ?? 'U').toUpperCase()
const fullName = given && last ? `${given} ${last}` : user?.email ?? 'User'
const shortName = (given || user?.email) ?? 'User'
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
const handleLogout = async () => {
setSigningOut(true)
await logout()
navigate('/login', { replace: true })
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Avatar className="rounded-lg cursor-pointer hover:opacity-80 transition-opacity size-9">
<AvatarImage src={avatarUrl ?? ''} alt={fullName} />
<AvatarFallback className={`text-sm font-semibold ${avatarColor}`}>
{initials}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{/* User info */}
<div className="px-2 py-2">
<p className="text-sm font-semibold truncate">{shortName}</p>
<p className="text-xs text-muted-foreground truncate">{user?.email ?? '—'}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => navigate('my-profile')}>
<User className="size-4" />
<span className="font-medium">Profile</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSettingsOpen(true)}>
<Settings className="size-4" />
<span className="font-medium">Settings</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
<LogOut className="size-4" />
<span className="font-medium">Sign out</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Settings dialog */}
<SettingsDialog open={settingsOpen} onClose={() => setSettingsOpen(false)} />
{/* Sign out overlay */}
<SignOutOverlay open={signingOut} />
</>
)
}
@@ -0,0 +1,148 @@
import { Bell, Trophy, BookOpen, Star, CheckCircle, Megaphone, Zap, ClipboardList } from "lucide-react";
import { cn } from "@/lib/utils";
import api from "@/utils/api.util";
const TYPE_ICON = {
achievement: Trophy,
course: BookOpen,
milestone: Star,
task: CheckCircle,
announcement: Megaphone,
tier_expired: Zap,
assessment: ClipboardList,
};
const TYPE_ACCENT = {
achievement: "bg-yellow-100 text-yellow-700 dark:bg-yellow-950/40 dark:text-yellow-400",
course: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400",
milestone: "bg-purple-100 text-purple-700 dark:bg-purple-950/40 dark:text-purple-400",
task: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400",
announcement: "bg-indigo-100 text-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-400",
tier_expired: "bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400",
assessment: "bg-blue-100 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400",
};
export function NotificationIcon({ type, className }) {
const Icon = TYPE_ICON[type] ?? Bell;
return <Icon className={cn("shrink-0", className)} />;
}
export function getTypeAccent(type) {
return TYPE_ACCENT[type] ?? "bg-muted text-foreground";
}
// Outline-button variant of TYPE_ACCENT — same per-type color family, tuned
// for a bordered CTA (e.g. "View task list" / "Open Link") instead of a
// filled icon badge, so the action button reads as the same type as the
// notification it belongs to rather than a generic button for every type.
const TYPE_BUTTON_CLASSES = {
achievement: "border-yellow-300 text-yellow-700 hover:bg-yellow-50 dark:border-yellow-800 dark:text-yellow-400 dark:hover:bg-yellow-950/40",
course: "border-blue-300 text-blue-700 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-400 dark:hover:bg-blue-950/40",
milestone: "border-purple-300 text-purple-700 hover:bg-purple-50 dark:border-purple-800 dark:text-purple-400 dark:hover:bg-purple-950/40",
task: "border-emerald-300 text-emerald-700 hover:bg-emerald-50 dark:border-emerald-800 dark:text-emerald-400 dark:hover:bg-emerald-950/40",
announcement: "border-indigo-300 text-indigo-700 hover:bg-indigo-50 dark:border-indigo-800 dark:text-indigo-400 dark:hover:bg-indigo-950/40",
tier_expired: "border-amber-300 text-amber-700 hover:bg-amber-50 dark:border-amber-800 dark:text-amber-400 dark:hover:bg-amber-950/40",
assessment: "border-blue-300 text-blue-700 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-400 dark:hover:bg-blue-950/40",
};
export function getTypeButtonClasses(type) {
return TYPE_BUTTON_CLASSES[type] ?? "";
}
export 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`;
}
// Admin-authored links only ever get typed as either an internal path
// ("/course/123") or a bare/https URL — a host typed without a scheme
// (e.g. "example.com") isn't "/"-prefixed, so it would otherwise fall
// through to window.open() and resolve as a broken relative path instead of
// navigating off-site. Prepend https:// whenever no scheme is present.
export function normalizeExternalUrl(url) {
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return url; // already has a scheme (https:, http:, mailto:, tel:, ...)
return `https://${url}`;
}
export function goToLink(url, navigate) {
if (url.startsWith("/")) navigate(url);
else window.open(normalizeExternalUrl(url), "_blank", "noopener,noreferrer");
}
// Resolves a course uuid to its numeric course_id — client course pages route by course_id, not uuid.
async function goToCourse(navigate, courseUuid) {
try {
const { data } = await api.get(`/client/courses/uuid/${courseUuid}`);
const courseId = data?.data?.course_id;
if (courseId) navigate(`/course/${courseId}`);
} catch {
// silent — dialog just stays open if the course can't be resolved
}
}
/**
* Per-type deep-link registry. Returns null when the notification carries no
* resolvable target (e.g. old rows created before ids were tracked).
*
* @returns {{ label: string, go: (navigate: Function) => (void | Promise<void>) } | null}
*/
export function resolveNotificationLink(type, data) {
if (!data) return null;
// An explicit admin-authored link (the broadcast form's "On Open" section)
// always wins over the type-based fallbacks below, same precedence the
// sticky banner uses — otherwise a notification with a configured button
// silently loses it whenever it's delivered as a regular notification
// instead of a sticky one.
if (data.linkUrl) {
return { label: data.linkLabel || "Open Link", go: (navigate) => goToLink(data.linkUrl, navigate) };
}
switch (type) {
case "course":
return data.courseUuid
? { label: "Go to course", go: (navigate) => goToCourse(navigate, data.courseUuid) }
: null;
case "assessment":
return data.courseUuid
? { label: "Go to course", go: (navigate) => goToCourse(navigate, data.courseUuid) }
: null;
case "task":
if (data.groupId && data.taskListId && data.taskId) {
return { label: "View task", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.taskListId}/task/${data.taskId}`) };
}
return (data.groupId && data.taskListId)
? { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.taskListId}`) }
: null;
case "tier_expired":
return data.planId
? { label: "Renew plan", go: (navigate) => navigate(`/subscriptions/view/${data.planId}`) }
: { label: "View plans", go: (navigate) => navigate("/subscriptions") };
case "announcement":
if (data.groupId && data.groupCode) {
return { label: "View my group", go: (navigate) => navigate(`/group/${data.groupId}`) };
}
if (data.targetType === "course" && data.targetId) {
return { label: "Go to course", go: (navigate) => goToCourse(navigate, data.targetId) };
}
if (data.targetType === "tier_plan" && data.targetId) {
return { label: "View plan", go: (navigate) => navigate(`/subscriptions/view/${data.targetId}`) };
}
if (data.targetType === "task_list" && data.groupId && data.targetId) {
return { label: "View task list", go: (navigate) => navigate(`/group/${data.groupId}/view/${data.targetId}`) };
}
return null;
default:
return null;
}
}
+78
View File
@@ -0,0 +1,78 @@
import * as React from "react"
import { Accordion as AccordionPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({
className,
...props
}) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props} />
);
}
function AccordionItem({
className,
...props
}) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props} />
);
}
function AccordionTrigger({
className,
children,
...props
}) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}>
{children}
<ChevronDownIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon
data-slot="accordion-trigger-icon"
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}>
<div
className={cn(
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}>
{children}
</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+174
View File
@@ -0,0 +1,174 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({
...props
}) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}) {
return (<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />);
}
function AlertDialogPortal({
...props
}) {
return (<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />);
}
function AlertDialogOverlay({
className,
...props
}) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props} />
);
}
function AlertDialogContent({
className,
size = "default",
...props
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]: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",
className
)}
{...props} />
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props} />
);
}
function AlertDialogFooter({
className,
...props
}) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props} />
);
}
function AlertDialogMedia({
className,
...props
}) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props} />
);
}
function AlertDialogTitle({
className,
...props
}) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props} />
);
}
function AlertDialogDescription({
className,
...props
}) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props} />
);
}
function AlertDialogAction({
className,
variant = "default",
size = "default",
...props
}) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
</Button>
);
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
</Button>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+78
View File
@@ -0,0 +1,78 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props} />
);
}
function AlertTitle({
className,
...props
}) {
return (
<div
data-slot="alert-title"
className={cn(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props} />
);
}
function AlertDescription({
className,
...props
}) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props} />
);
}
function AlertAction({
className,
...props
}) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props} />
);
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
@@ -0,0 +1,11 @@
"use client"
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
function AspectRatio({
...props
}) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio }
+105
View File
@@ -0,0 +1,105 @@
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props} />
);
}
function AvatarImage({
className,
...props
}) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full rounded-full object-cover", className)}
{...props} />
);
}
function AvatarFallback({
className,
...props
}) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props} />
);
}
function AvatarBadge({
className,
...props
}) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props} />
);
}
function AvatarGroup({
className,
...props
}) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props} />
);
}
function AvatarGroupCount({
className,
...props
}) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props} />
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props} />
);
}
export { Badge, badgeVariants }
+121
View File
@@ -0,0 +1,121 @@
import * as React from "react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({
className,
...props
}) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props} />
);
}
function BreadcrumbList({
className,
...props
}) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props} />
);
}
function BreadcrumbItem({
className,
...props
}) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props} />
);
}
function BreadcrumbLink({
asChild,
className,
...props
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("transition-colors hover:text-foreground", className)}
{...props} />
);
}
function BreadcrumbPage({
className,
...props
}) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props} />
);
}
function BreadcrumbSeparator({
children,
className,
...props
}) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}>
{children ?? (
<ChevronRightIcon />
)}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-5 items-center justify-center [&>svg]:size-4", className)}
{...props}>
<MoreHorizontalIcon />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -0,0 +1,78 @@
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"group/button-group flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg!",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg!",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props} />
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "div"
return (
<Comp
className={cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props} />
);
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props} />
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+63
View File
@@ -0,0 +1,63 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props} />
);
}
export { Button, buttonVariants }
+182
View File
@@ -0,0 +1,182 @@
"use client"
import * as React from "react"
import { DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
locale,
formatters,
components,
...props
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
locale={locale}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString(locale?.code, { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("relative flex flex-col gap-4 md:flex-row", defaultClassNames.months),
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
nav: cn(
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
defaultClassNames.button_next
),
month_caption: cn(
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
defaultClassNames.dropdowns
),
dropdown_root: cn("relative rounded-(--cell-radius)", defaultClassNames.dropdown_root),
dropdown: cn("absolute inset-0 bg-popover opacity-0", defaultClassNames.dropdown),
caption_label: cn("font-medium select-none", captionLayout === "label"
? "text-sm"
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground", defaultClassNames.caption_label),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
defaultClassNames.weekday
),
week: cn("mt-2 flex w-full", defaultClassNames.week),
week_number_header: cn("w-(--cell-size) select-none", defaultClassNames.week_number_header),
week_number: cn(
"text-[0.8rem] text-muted-foreground select-none",
defaultClassNames.week_number
),
day: cn(
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
defaultClassNames.day
),
range_start: cn(
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn(
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
defaultClassNames.range_end
),
today: cn(
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (<ChevronLeftIcon className={cn("size-4", className)} {...props} />);
}
if (orientation === "right") {
return (<ChevronRightIcon className={cn("size-4", className)} {...props} />);
}
return (<ChevronDownIcon className={cn("size-4", className)} {...props} />);
},
DayButton: ({ ...props }) => (
<CalendarDayButton locale={locale} {...props} />
),
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div
className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props} />
);
}
function CalendarDayButton({
className,
day,
modifiers,
locale,
...props
}) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString(locale?.code)}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props} />
);
}
export { Calendar, CalendarDayButton }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props} />
);
}
function CardHeader({
className,
...props
}) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props} />
);
}
function CardTitle({
className,
...props
}) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props} />
);
}
function CardDescription({
className,
...props
}) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props} />
);
}
function CardAction({
className,
...props
}) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} />
);
}
function CardContent({
className,
...props
}) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props} />
);
}
function CardFooter({
className,
...props
}) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props} />
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+194
View File
@@ -0,0 +1,194 @@
import * as React from "react"
import useEmblaCarousel from "embla-carousel-react";
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
const CarouselContext = React.createContext(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}) {
const [carouselRef, api] = useEmblaCarousel({
...opts,
axis: orientation === "horizontal" ? "x" : "y",
}, plugins)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback((event) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
}, [scrollPrev, scrollNext])
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
};
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({
className,
...props
}) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content">
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props} />
</div>
);
}
function CarouselItem({
className,
...props
}) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props} />
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn("absolute touch-manipulation rounded-full", orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90", className)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn("absolute touch-manipulation rounded-full", orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", className)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
);
}
export { Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, useCarousel };

Some files were not shown because too many files have changed in this diff Show More