mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
merge new_starr_app@qas history into apps/web
git-subtree-dir: apps/web git-subtree-mainline:eaa6d2276agit-subtree-split:459af9d46a
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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";
|
||||
}
|
||||
@@ -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 · max {MAX_MB} MB
|
||||
</p>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive font-medium mt-2">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step: crop ── */}
|
||||
{step === 'crop' && (
|
||||
<div>
|
||||
{/* Cropper canvas */}
|
||||
<div className="relative h-72 bg-zinc-900">
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
style={{
|
||||
containerStyle: { borderRadius: 0 },
|
||||
cropAreaStyle: { border: '2px solid rgba(255,255,255,0.85)', boxShadow: '0 0 0 9999px rgba(0,0,0,0.55)' },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="px-5 py-4 space-y-2.5 border-b bg-background">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.max(1, +(z - 0.1).toFixed(2)))}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ZoomOut size={15} />
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 h-1.5 appearance-none rounded-full bg-border cursor-pointer
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:size-4
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-foreground
|
||||
[&::-webkit-slider-thumb]:cursor-pointer
|
||||
[&::-webkit-slider-thumb]:shadow-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.min(3, +(z + 0.1).toFixed(2)))}
|
||||
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ZoomIn size={15} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Drag to reposition · Scroll or pinch to zoom
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Footer ── */}
|
||||
{/* mx-0 mb-0 rounded-none bg-transparent override DialogFooter's negative margins
|
||||
that assume p-4 on DialogContent — we use p-0 so those would overshoot */}
|
||||
<DialogFooter className="mx-0 mb-0 rounded-none bg-transparent border-t px-5 py-4 sm:justify-end">
|
||||
{step === 'pick' && currentAvatarUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10 gap-1.5 sm:mr-auto"
|
||||
onClick={handleDelete}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading
|
||||
? <Loader2 className="size-3.5 animate-spin" />
|
||||
: <Trash2 className="size-3.5" />
|
||||
}
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" size="sm" disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
{step === 'crop' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleUpload}
|
||||
disabled={!croppedAreaPixels || loading}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{loading && <Loader2 className="size-3.5 animate-spin" />}
|
||||
Upload photo
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & Time</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Choose how dates and times are displayed across the platform.
|
||||
Your locale format is taken from your browser's language setting.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-3 block">Timezone Display</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<TimezoneOption
|
||||
value="local"
|
||||
label="Local"
|
||||
description="Dates shown in your device's local timezone."
|
||||
active={timezone === 'local'}
|
||||
onClick={setTimezone}
|
||||
/>
|
||||
<TimezoneOption
|
||||
value="UTC"
|
||||
label="UTC"
|
||||
description="Dates shown in Coordinated Universal Time (UTC+0)."
|
||||
active={timezone === 'UTC'}
|
||||
onClick={setTimezone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/40 p-3 text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium text-foreground">Preview</p>
|
||||
<p>Date: <span className="text-foreground">{new Date().toLocaleDateString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}</span></p>
|
||||
<p>Date & Time: <span className="text-foreground">{new Date().toLocaleString(navigator.language, { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', ...(timezone === 'UTC' ? { timeZone: 'UTC' } : {}) })}</span></p>
|
||||
{timezone === 'UTC' && <p className="text-amber-600 dark:text-amber-400 font-medium">All times shown in UTC</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user