ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:07:20 +08:00
parent 56d984a26a
commit fbef7cb6e6
283 changed files with 25961 additions and 1072 deletions
+19 -1
View File
@@ -1,6 +1,6 @@
// components/generic/CMS/AddBlockMenu.jsx
import { Plus, Type, Image, ImagePlay, Video, VideoIcon } from "lucide-react";
import { Plus, Type, Image, ImagePlay, Video, VideoIcon, Music2, Code2, FileText } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -42,6 +42,24 @@ const BLOCK_TYPES = [
description: "Text beside a video",
icon: <Video className="h-4 w-4" />,
},
{
type: "audio",
label: "Audio",
description: "An audio player",
icon: <Music2 className="h-4 w-4" />,
},
{
type: "code",
label: "Code",
description: "A syntax-highlighted code block",
icon: <Code2 className="h-4 w-4" />,
},
{
type: "markdown",
label: "Markdown",
description: "Rich text written in Markdown",
icon: <FileText className="h-4 w-4" />,
},
];
export function AddBlockMenu({ onAdd }) {
@@ -59,6 +59,7 @@ function AssetCard({ asset, selected, onSelect }) {
}
export function AssetPickerSheet({ open, onOpenChange, fileType, onSelect }) {
if (!open) return null;
const { fetchAssets, assets, pagination, loading } = useAssets();
const [search, setSearch] = useState("");
@@ -0,0 +1,297 @@
import { useState, useRef, useCallback } from 'react'
import Cropper from 'react-easy-crop'
import { Upload, Trash2, Loader2, ZoomIn, ZoomOut, ArrowLeft } from 'lucide-react'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
const MAX_MB = 5
const MAX_BYTES = MAX_MB * 1024 * 1024
const ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image()
img.addEventListener('load', () => resolve(img))
img.addEventListener('error', reject)
img.setAttribute('crossOrigin', 'anonymous')
img.src = src
})
}
async function cropToBlob(imageSrc, pixels, outputSize = 512) {
const img = await loadImage(imageSrc)
const canvas = document.createElement('canvas')
canvas.width = outputSize
canvas.height = outputSize
const ctx = canvas.getContext('2d')
ctx.drawImage(img, pixels.x, pixels.y, pixels.width, pixels.height, 0, 0, outputSize, outputSize)
return new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.93))
}
export default function AvatarUploadDialog({
open,
onClose,
currentAvatarUrl = '',
initials = '?',
onUpload,
onDelete,
loading = false,
}) {
const [step, setStep] = useState('pick') // 'pick' | 'crop'
const [imageSrc, setImageSrc] = useState(null)
const [fileName, setFileName] = useState('')
const [dragOver, setDragOver] = useState(false)
const [error, setError] = useState('')
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null)
const inputRef = useRef(null)
const reset = () => {
setStep('pick')
setImageSrc(null)
setFileName('')
setError('')
setDragOver(false)
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
}
const handleClose = () => { reset(); onClose() }
const validate = (f) => {
if (!ALLOWED.includes(f.type)) {
setError('Unsupported format. Use JPEG, PNG, WebP or GIF.')
return false
}
if (f.size > MAX_BYTES) {
setError(`File too large — max is ${MAX_MB} MB.`)
return false
}
return true
}
const applyFile = (f) => {
setError('')
if (!validate(f)) return
const reader = new FileReader()
reader.onload = (e) => {
setImageSrc(e.target.result)
setFileName(f.name)
setCrop({ x: 0, y: 0 })
setZoom(1)
setStep('crop')
}
reader.readAsDataURL(f)
}
const handleInputChange = (e) => {
const f = e.target.files?.[0]
if (f) applyFile(f)
e.target.value = ''
}
const handleDrop = (e) => {
e.preventDefault()
setDragOver(false)
const f = e.dataTransfer.files?.[0]
if (f) applyFile(f)
}
const onCropComplete = useCallback((_, pixels) => {
setCroppedAreaPixels(pixels)
}, [])
const handleUpload = async () => {
if (!croppedAreaPixels) return
const blob = await cropToBlob(imageSrc, croppedAreaPixels)
const file = new File([blob], fileName || 'avatar.jpg', { type: 'image/jpeg' })
const result = await onUpload(file)
if (result?.success) handleClose()
}
const handleDelete = async () => {
const result = await onDelete()
if (result?.success) handleClose()
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md p-0 overflow-hidden gap-0">
{/* ── Header ── */}
<DialogHeader className="px-5 pt-5 pb-4 border-b">
<div className="flex items-center gap-2">
{step === 'crop' && (
<button
onClick={() => { setStep('pick'); setImageSrc(null) }}
className="p-1 -ml-1 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<ArrowLeft size={16} />
</button>
)}
<DialogTitle className="text-base">
{step === 'crop' ? 'Adjust photo' : 'Change Avatar'}
</DialogTitle>
</div>
</DialogHeader>
{/* ── Step: pick ── */}
{step === 'pick' && (
<div className="px-5 py-5 space-y-4">
<div className="flex justify-center pb-1">
<Avatar className="size-24 ring-2 ring-border">
<AvatarImage src={currentAvatarUrl} />
<AvatarFallback className="text-2xl font-semibold">{initials}</AvatarFallback>
</Avatar>
</div>
<Separator />
<div
onClick={() => inputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-xl px-6 py-6 text-center cursor-pointer transition-colors select-none',
dragOver
? 'border-primary bg-primary/5'
: 'border-border hover:border-muted-foreground/40 hover:bg-muted/40',
error && !dragOver && 'border-destructive/60 bg-destructive/5'
)}
>
<input
ref={inputRef}
type="file"
accept={ALLOWED.join(',')}
className="hidden"
onChange={handleInputChange}
/>
<div className={cn(
'size-10 rounded-full flex items-center justify-center mx-auto mb-3',
error ? 'bg-destructive/10' : 'bg-muted'
)}>
<Upload className={cn('size-5', error ? 'text-destructive/70' : 'text-muted-foreground')} />
</div>
<p className="text-sm font-medium">
{dragOver ? 'Drop to select' : 'Click or drag & drop to upload'}
</p>
<p className="text-xs text-muted-foreground mt-1">
JPEG, PNG, WebP or GIF &middot; max {MAX_MB} MB
</p>
{error && (
<p className="text-xs text-destructive font-medium mt-2">{error}</p>
)}
</div>
</div>
)}
{/* ── Step: crop ── */}
{step === 'crop' && (
<div>
{/* Cropper canvas */}
<div className="relative h-72 bg-zinc-900">
<Cropper
image={imageSrc}
crop={crop}
zoom={zoom}
aspect={1}
cropShape="round"
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
style={{
containerStyle: { borderRadius: 0 },
cropAreaStyle: { border: '2px solid rgba(255,255,255,0.85)', boxShadow: '0 0 0 9999px rgba(0,0,0,0.55)' },
}}
/>
</div>
{/* Zoom controls */}
<div className="px-5 py-4 space-y-2.5 border-b bg-background">
<div className="flex items-center gap-3">
<button
onClick={() => setZoom((z) => Math.max(1, +(z - 0.1).toFixed(2)))}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<ZoomOut size={15} />
</button>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="flex-1 h-1.5 appearance-none rounded-full bg-border cursor-pointer
[&::-webkit-slider-thumb]:appearance-none
[&::-webkit-slider-thumb]:size-4
[&::-webkit-slider-thumb]:rounded-full
[&::-webkit-slider-thumb]:bg-foreground
[&::-webkit-slider-thumb]:cursor-pointer
[&::-webkit-slider-thumb]:shadow-sm"
/>
<button
onClick={() => setZoom((z) => Math.min(3, +(z + 0.1).toFixed(2)))}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<ZoomIn size={15} />
</button>
</div>
<p className="text-xs text-center text-muted-foreground">
Drag to reposition &middot; Scroll or pinch to zoom
</p>
</div>
</div>
)}
{/* ── Footer ── */}
{/* mx-0 mb-0 rounded-none bg-transparent override DialogFooter's negative margins
that assume p-4 on DialogContent — we use p-0 so those would overshoot */}
<DialogFooter className="mx-0 mb-0 rounded-none bg-transparent border-t px-5 py-4 sm:justify-end">
{step === 'pick' && currentAvatarUrl && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10 gap-1.5 sm:mr-auto"
onClick={handleDelete}
disabled={loading}
>
{loading
? <Loader2 className="size-3.5 animate-spin" />
: <Trash2 className="size-3.5" />
}
Remove
</Button>
)}
<DialogClose asChild>
<Button variant="outline" size="sm" disabled={loading}>
Cancel
</Button>
</DialogClose>
{step === 'crop' && (
<Button
size="sm"
onClick={handleUpload}
disabled={!croppedAreaPixels || loading}
className="gap-1.5"
>
{loading && <Loader2 className="size-3.5 animate-spin" />}
Upload photo
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+28 -16
View File
@@ -1,11 +1,14 @@
// components/generic/CMS/BlockList.jsx
import { BlockWrapper } from "./BlockWrapper";
import { TextBlock } from "./Blocks/TextBlock";
import { ImageBlock } from "./Blocks/ImageBlock";
import { TextImageBlock } from "./Blocks/TextImageBlock";
import { VideoBlock } from "./Blocks/VideoBlock";
import { TextVideoBlock } from "./Blocks/TextVideoBlock";
import { BlockWrapper } from "./BlockWrapper";
import { TextBlock } from "./Blocks/Admin/TextBlock";
import { ImageBlock } from "./Blocks/Admin/ImageBlock";
import { TextImageBlock } from "./Blocks/Admin/TextImageBlock";
import { VideoBlock } from "./Blocks/Admin/VideoBlock";
import { TextVideoBlock } from "./Blocks/Admin/TextVideoBlock";
import { AudioBlock } from "./Blocks/Admin/AudioBlock";
import { CodeBlock } from "./Blocks/Admin/CodeBlock";
import { MarkdownBlock } from "./Blocks/Admin/MarkdownBlock";
// ─── Block renderer ───────────────────────────────────────────────────────────
//
@@ -25,13 +28,19 @@ function BlockContent({ block, onUpdate }) {
/>
);
case "image":
return <ImageBlock content={content} onUpdate={onUpdate} />;
return <ImageBlock content={content} onUpdate={onUpdate} />;
case "text-image":
return <TextImageBlock blockId={id} content={content} onUpdate={onUpdate} />;
return <TextImageBlock blockId={id} content={content} onUpdate={onUpdate} />;
case "video":
return <VideoBlock content={content} onUpdate={onUpdate} />;
return <VideoBlock content={content} onUpdate={onUpdate} />;
case "text-video":
return <TextVideoBlock blockId={id} content={content} onUpdate={onUpdate} />;
return <TextVideoBlock blockId={id} content={content} onUpdate={onUpdate} />;
case "audio":
return <AudioBlock content={content} onUpdate={onUpdate} />;
case "code":
return <CodeBlock content={content} onUpdate={onUpdate} />;
case "markdown":
return <MarkdownBlock content={content} onUpdate={onUpdate} />;
default:
return <p className="text-sm text-muted-foreground">Unknown block type.</p>;
}
@@ -40,11 +49,14 @@ function BlockContent({ block, onUpdate }) {
// ─── Default content per type ─────────────────────────────────────────────────
export const DEFAULT_CONTENT = {
"text": { body: "" },
"image": { asset_id: null, url: "", alt: "" },
"text": { body: "" },
"image": { asset_id: null, url: "", alt: "" },
"text-image": { body: "", asset_id: null, url: "", alt: "", image_position: "right" },
"video": { asset_id: null, url: "", thumbnail_url: "" },
"video": { asset_id: null, url: "", thumbnail_url: "" },
"text-video": { body: "", asset_id: null, url: "", thumbnail_url: "", video_position: "right" },
"audio": { asset_id: null, url: "", title: "", artist: "", tag: "", thumbnail: "" },
"code": { language: "javascript", code: "" },
"markdown": { body: "" },
};
// ─── List ─────────────────────────────────────────────────────────────────────
@@ -69,9 +81,9 @@ export function BlockList({ blocks, onUpdate, onMove, onDelete }) {
type={block.type}
index={index}
total={blocks.length}
onMoveUp={() => onMove(block.id, "up")}
onMoveDown={() => onMove(block.id, "down")}
onDelete={() => onDelete(block.id)}
onMoveUp={() => onMove(block.id, "up")}
onMoveDown={() => onMove(block.id, "down")}
onDelete={() => onDelete(block.id)}
>
<BlockContent
block={block}
+67 -70
View File
@@ -1,83 +1,80 @@
// components/generic/CMS/BlockWrapper.jsx
import { GripVertical, ChevronUp, ChevronDown, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
const BLOCK_LABELS = {
"text": "Text",
"image": "Image",
"text-image": "Text + Image",
"video": "Video",
"text-video": "Text + Video",
"text": "Text",
"image": "Image",
"text-image": "Text + Image",
"video": "Video",
"text-video": "Text + Video",
"audio": "Audio",
};
export function BlockWrapper({
type,
index,
total,
onMoveUp,
onMoveDown,
onDelete,
children,
type,
index,
total,
onMoveUp,
onMoveDown,
onDelete,
children,
}) {
return (
<div className="group relative rounded-lg border bg-card transition-shadow hover:shadow-sm">
return (
<div className="group relative rounded-lg border bg-card transition-shadow hover:shadow-sm">
{/* ── Top toolbar ── */}
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 rounded-t-lg">
{/* Left — drag handle + block type */}
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<Badge variant="secondary" className="text-xs">
{BLOCK_LABELS[type] ?? type}
</Badge>
<span className="text-xs text-muted-foreground">
Block {index + 1}
</span>
</div>
{/* Right — move + delete */}
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={index === 0}
onClick={onMoveUp}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={index === total - 1}
onClick={onMoveDown}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* ── Block content ── */}
<div className="p-4">
{children}
</div>
{/* ── Top toolbar ── */}
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/40 rounded-t-lg">
{/* Left — drag handle + block type */}
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<Badge variant="secondary" className="text-xs">
{BLOCK_LABELS[type] ?? type}
</Badge>
<span className="text-xs text-muted-foreground">
Block {index + 1}
</span>
</div>
);
{/* Right — move + delete */}
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={index === 0}
onClick={onMoveUp}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={index === total - 1}
onClick={onMoveDown}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={onDelete}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* ── Block content ── */}
<div className="p-4">
{children}
</div>
</div>
);
}
@@ -0,0 +1,392 @@
import { useRef, useState, useEffect, useCallback } from "react";
import {
Music2,
RotateCcw,
RotateCw,
Play,
Pause,
VolumeOff,
Volume2,
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { AssetPickerSheet } from "../../AssetPickerSheet";
import api from "@/utils/api.util";
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtTime = (s) => {
if (!s || isNaN(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
};
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
// ─── AudioBlock (Admin) ───────────────────────────────────────────────────────
export function AudioBlock({ content, onUpdate, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
// ── S3 token state — fetched when storage_provider is "s3" ───────────────
const [streamSrc, setStreamSrc] = useState(null);
const [streamThumb, setStreamThumb] = useState(null);
const [tokenLoading, setTokenLoading] = useState(false);
const audioRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [buffered, setBuffered] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [speedIdx, setSpeedIdx] = useState(2); // 1×
const assetId = content.asset_id ?? null;
const storageProvider = content.storage_provider ?? null;
const isS3 = storageProvider === "s3";
// For S3 assets, use the stream URL fetched via admin token.
// For all other providers, use the raw url/src from block content.
const src = isS3 ? (streamSrc ?? "") : (content.url ?? content.src ?? "");
const title = content.title ?? "Audio";
const artist = content.artist ?? "";
const tag = content.tag ?? "";
const thumbnail = isS3 ? (streamThumb ?? content.thumbnail ?? null) : (content.thumbnail ?? null);
// ── Fetch admin token for S3 assets ───────────────────────────────────────
useEffect(() => {
if (!assetId || !isS3) {
setStreamSrc(null);
setStreamThumb(null);
return;
}
let cancelled = false;
setTokenLoading(true);
api.post("/admin/media/token", { asset_id: assetId })
.then(({ data }) => {
if (cancelled) return;
const { token, thumbnail_url } = data?.data ?? {};
if (token) setStreamSrc(`${API_BASE}/client/media/stream/${token}`);
if (thumbnail_url) setStreamThumb(thumbnail_url);
})
.catch(() => { /* non-fatal — player shows nothing */ })
.finally(() => { if (!cancelled) setTokenLoading(false); });
return () => { cancelled = true; };
}, [assetId, isS3]);
// ── Audio events ──────────────────────────────────────────────────────────
const onTimeUpdate = useCallback(() => setCurrentTime(audioRef.current?.currentTime ?? 0), []);
const onLoadedMeta = useCallback(() => setDuration(audioRef.current?.duration ?? 0), []);
const onEnded = useCallback(() => setPlaying(false), []);
const onProgress = useCallback(() => {
const el = audioRef.current;
if (el?.buffered.length && el.duration) {
setBuffered((el.buffered.end(el.buffered.length - 1) / el.duration) * 100);
}
}, []);
// ── Controls ──────────────────────────────────────────────────────────────
const togglePlay = () => {
const el = audioRef.current;
if (!el) return;
if (playing) { el.pause(); setPlaying(false); }
else { el.play(); setPlaying(true); }
};
const seek = (e) => {
const el = audioRef.current;
const bar = e.currentTarget;
const pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth;
el.currentTime = pct * duration;
};
const skip = (secs) => {
const el = audioRef.current;
if (!el) return;
el.currentTime = Math.min(Math.max(0, el.currentTime + secs), duration);
};
const handleVolume = (e) => {
const v = parseFloat(e.target.value);
setVolume(v);
if (audioRef.current) audioRef.current.volume = v;
setMuted(v === 0);
};
const toggleMute = () => {
const el = audioRef.current;
if (!el) return;
el.muted = !muted;
setMuted(!muted);
};
const cycleSpeed = () => {
const next = (speedIdx + 1) % SPEEDS.length;
setSpeedIdx(next);
if (audioRef.current) audioRef.current.playbackRate = SPEEDS[next];
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
// ── Asset picker handler ──────────────────────────────────────────────────
//
// Stores all metadata needed by the client AudioBlock at render time so
// the client never needs a separate API call to fetch asset details.
//
// Fields saved to block content:
// asset_id — used by client for the secure token flow
// url — used by admin player (direct src); ignored by client for S3
// title — display name (kept if already customised, else asset name)
// artist — cleared on new pick so stale artist doesn't carry over
// thumbnail — cover art from asset.thumbnail_url
// tag — file extension badge e.g. "MP3"
//
const handleSelect = (asset) => {
onUpdate({
asset_id: asset.asset_id,
// url is null for S3 (redacted server-side); Chibisafe/CDN keeps its raw URL
url: asset.file_url ?? null,
storage_provider: asset.storage_provider ?? null,
title: asset.display_name,
artist: "",
thumbnail: asset.thumbnail_url ?? null,
tag: asset.extension?.toUpperCase() ?? "",
});
setPlaying(false);
setCurrentTime(0);
setDuration(0);
setBuffered(0);
setStreamSrc(null);
setStreamThumb(null);
};
// ─────────────────────────────────────────────────────────────────────────
return (
<div className="space-y-3">
{!readOnly && <Label>Audio</Label>}
{isS3 && tokenLoading ? (
<div className="w-full max-w-lg rounded-xl border border-border bg-card flex items-center justify-center h-28">
<div className="w-5 h-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
</div>
) : src ? (
<div className="w-full max-w-lg rounded-xl overflow-hidden border border-border bg-card text-card-foreground shadow-sm">
<audio
ref={audioRef}
src={src}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMeta}
onEnded={onEnded}
onProgress={onProgress}
preload="metadata"
/>
{/* ── Header ── */}
<div className="relative overflow-hidden">
{thumbnail ? (
<div
className="absolute inset-0 scale-110"
style={{
backgroundImage: `url(${thumbnail})`,
backgroundSize: "cover",
backgroundPosition: "center",
filter: "blur(24px) brightness(0.35)",
}}
/>
) : (
<div className="absolute inset-0 bg-muted" />
)}
<div className="relative z-10 flex items-center gap-4 p-4 text-white">
<div className="shrink-0 w-20 h-20 rounded-md overflow-hidden bg-black/25">
{thumbnail ? (
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<Music2 className="w-7 h-7 text-white/30" />
</div>
)}
</div>
<div className="flex flex-col gap-1 flex-1 min-w-0">
{tag && (
<div className="text-xs font-semibold rounded-full uppercase px-2 py-0.5 bg-card text-card-foreground border w-fit">
{tag}
</div>
)}
<p className="text-sm font-semibold leading-snug line-clamp-3">{title}</p>
{artist && <p className="text-xs text-white/60 line-clamp-2">{artist}</p>}
</div>
</div>
</div>
{/* ── Controls ── */}
<div className="px-4 pb-4 pt-3 space-y-3">
{/* Progress bar */}
<div className="flex items-center gap-2.5">
<span className="text-xs tabular-nums text-muted-foreground w-8 shrink-0">
{fmtTime(currentTime)}
</span>
<div
className="flex-1 h-1.5 rounded-full bg-muted cursor-pointer relative group"
onClick={seek}
role="slider"
aria-label="Seek"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
>
<div
className="absolute inset-y-0 left-0 rounded-full bg-muted-foreground/25 transition-[width] duration-300"
style={{ width: `${buffered}%` }}
/>
<div
className="h-full rounded-full bg-primary transition-all relative"
style={{ width: `${progress}%` }}
/>
<div
className="absolute top-1/2 w-3 h-3 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity"
style={{ left: `${progress}%`, transform: "translate(-50%, -50%)" }}
/>
</div>
<span className="text-xs tabular-nums text-muted-foreground w-8 shrink-0 text-right">
{fmtTime(duration)}
</span>
</div>
{/* Button row */}
<div className="grid grid-cols-3 items-center">
{/* Volume */}
<div className="flex items-center gap-1.5">
<button
onClick={toggleMute}
aria-label={muted ? "Unmute" : "Mute"}
className="w-7 h-7 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors"
>
{muted || volume === 0
? <VolumeOff className="size-4" />
: <Volume2 className="size-4" />
}
</button>
<input
type="range" min="0" max="1" step="0.05"
value={muted ? 0 : volume}
onChange={handleVolume}
aria-label="Volume"
className="w-16 h-1.5"
/>
</div>
{/* Play controls */}
<div className="flex items-center justify-center gap-2">
<button
onClick={() => skip(-10)}
aria-label="Rewind 10s"
className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors"
>
<RotateCcw className="size-4" />
</button>
<button
onClick={togglePlay}
aria-label={playing ? "Pause" : "Play"}
className="p-3 rounded-full flex items-center justify-center bg-primary text-primary-foreground hover:opacity-80 transition-opacity active:scale-95 shadow-md"
>
{playing
? <Pause className="size-4" />
: <Play className="size-4" />
}
</button>
<button
onClick={() => skip(10)}
aria-label="Forward 10s"
className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors"
>
<RotateCw className="size-4" />
</button>
</div>
{/* Speed */}
<div className="flex items-center justify-end">
<button
onClick={cycleSpeed}
aria-label={`Speed ${SPEEDS[speedIdx]}x`}
className="h-7 px-2 rounded text-sm font-medium text-foreground hover:bg-muted transition-colors tabular-nums"
>
{SPEEDS[speedIdx]}x
</button>
</div>
</div>
</div>
{/* ── Change audio (admin only) ── */}
{!readOnly && (
<div className="px-4 pb-4">
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full text-sm text-muted-foreground border rounded-md py-1.5 hover:bg-muted transition-colors"
>
Change audio
</button>
</div>
)}
</div>
) : (
<button
type="button"
onClick={() => !readOnly && setPickerOpen(true)}
className="w-full py-12 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
>
<Music2 className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Click to select an audio file</p>
</button>
)}
{/* ── Metadata fields (admin only) ─────────────────────────────────
These fields are saved into block content and rendered directly
by the client AudioBlock — no extra API call needed at runtime. */}
{!readOnly && src && (
<div className="space-y-3 pt-1">
<div className="space-y-1.5">
<Label htmlFor="audio-title">Title</Label>
<Textarea
id="audio-title"
rows={2}
value={content.title ?? ""}
onChange={(e) => onUpdate({ ...content, title: e.target.value })}
placeholder="Track title"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="audio-artist">Artist / Subtitle</Label>
<Textarea
id="audio-artist"
rows={2}
value={content.artist ?? ""}
onChange={(e) => onUpdate({ ...content, artist: e.target.value })}
placeholder="Artist name or subtitle"
/>
</div>
</div>
)}
{/* ── Asset picker ── */}
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="audio"
onSelect={handleSelect}
/>
)}
</div>
);
}
@@ -0,0 +1,48 @@
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
const LANGUAGES = [
{ value: "html", label: "HTML" },
{ value: "css", label: "CSS" },
{ value: "javascript", label: "JavaScript" },
{ value: "typescript", label: "TypeScript" },
{ value: "jsx", label: "JSX / TSX" },
{ value: "python", label: "Python" },
{ value: "sql", label: "SQL" },
{ value: "bash", label: "Shell / Bash" },
{ value: "json", label: "JSON" },
{ value: "text", label: "Plain Text" },
];
export function CodeBlock({ content, onUpdate }) {
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<Label>Language</Label>
<select
value={content.language ?? "javascript"}
onChange={(e) => onUpdate({ ...content, language: e.target.value })}
className="h-7 text-xs border rounded px-2 bg-background cursor-pointer"
>
{LANGUAGES.map((l) => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label>Code</Label>
<Textarea
value={content.code ?? ""}
onChange={(e) => onUpdate({ ...content, code: e.target.value })}
placeholder="// Write or paste your code here..."
className="font-mono text-sm min-h-[180px] resize-y leading-relaxed"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
</div>
</div>
);
}
@@ -2,7 +2,7 @@ import { useState } from "react";
import { ImageIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { AssetPickerSheet } from "../AssetPickerSheet";
import { AssetPickerSheet } from "../../AssetPickerSheet";
function MediaPlaceholder({ onClick }) {
@@ -20,7 +20,7 @@ function MediaPlaceholder({ onClick }) {
);
}
export function ImageBlock({ content, onUpdate }) {
export function ImageBlock({ content, onUpdate, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
return (
@@ -56,16 +56,18 @@ export function ImageBlock({ content, onUpdate }) {
</div>
)}
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
url: asset.file_url,
})}
/>
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
url: asset.file_url,
})}
/>
)}
</div>
);
}
@@ -0,0 +1,276 @@
import { useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import {
Bold, Italic, Heading2, Code, Code2,
Link2, List, ListOrdered, Quote, Minus, Eye, Pencil,
} from "lucide-react";
// ─── Shared markdown styles ───────────────────────────────────────────────────
// Exported so Client/MarkdownBlock can import and inject the same rules.
export const MARKDOWN_STYLES = `
.md-body { line-height: 1.7; font-size: 1rem; }
.md-body h1 { font-size: 1.75rem; font-weight: 700; margin: 1.25rem 0 0.5rem; line-height: 1.2; }
.md-body h2 { font-size: 1.375rem; font-weight: 600; margin: 1.1rem 0 0.45rem; line-height: 1.25; }
.md-body h3 { font-size: 1.125rem; font-weight: 600; margin: 1rem 0 0.4rem; line-height: 1.3; }
.md-body h4 { font-size: 1rem; font-weight: 600; margin: 0.9rem 0 0.35rem; }
.md-body p { margin: 0 0 0.85rem; line-height: 1.75; }
.md-body ul { list-style: disc; padding-left: 1.4rem; margin: 0.4rem 0 0.85rem; }
.md-body ol { list-style: decimal; padding-left: 1.4rem; margin: 0.4rem 0 0.85rem; }
.md-body li { margin-bottom: 0.3rem; line-height: 1.7; }
/* Task list checkboxes */
.md-body input[type="checkbox"] { margin-right: 0.4rem; accent-color: hsl(var(--primary)); }
.md-body a { color: hsl(var(--primary)); text-decoration: underline; }
.md-body strong { font-weight: 700; }
.md-body em { font-style: italic; }
.md-body del { text-decoration: line-through; opacity: 0.7; }
/* Inline code */
.md-body :not(pre) > code {
font-family: 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
font-size: 0.875em;
background: hsl(var(--muted));
color: hsl(var(--foreground));
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
border: 1px solid hsl(var(--border));
word-break: break-word;
}
/* Code block */
.md-body pre {
background: hsl(220 13% 12%);
color: hsl(220 14% 88%);
border-radius: 0.5rem;
padding: 0.875rem 1rem;
overflow-x: auto;
margin: 0.75rem 0;
border: 1px solid hsl(220 13% 22%);
-webkit-overflow-scrolling: touch;
}
.md-body pre code {
background: none;
border: none;
padding: 0;
font-size: 0.875rem;
color: inherit;
white-space: pre;
word-break: normal;
font-family: 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
}
/* Blockquote */
.md-body blockquote {
border-left: 3px solid hsl(var(--primary));
margin: 0.75rem 0;
padding: 0.4rem 0 0.4rem 1rem;
color: hsl(var(--muted-foreground));
font-style: italic;
}
.md-body blockquote p { margin-bottom: 0; }
/* Horizontal rule */
.md-body hr {
border: none;
border-top: 1px solid hsl(var(--border));
margin: 1.25rem 0;
}
/* Tables (GFM) — mirrors WYSIWYG table technique: display:block + box-shadow borders */
.md-body table {
display: block;
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-collapse: separate;
border-spacing: 0;
margin: 0.75rem 0;
font-size: 0.875rem;
border: 1.5px solid #cbd5e1;
border-radius: 0.375rem;
}
.md-body th {
background: #f1f5f9;
color: #1e293b;
font-weight: 600;
text-align: left;
padding: 0.45rem 0.75rem;
white-space: nowrap;
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
}
.md-body td {
padding: 0.4rem 0.75rem;
vertical-align: top;
word-break: break-word;
min-width: 4rem;
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
}
.md-body tbody tr:nth-child(even) td { background: #f8fafc; }
@media (min-width: 1024px) {
.md-body { font-size: 1.05rem; }
.md-body h1 { font-size: 2rem; }
.md-body h2 { font-size: 1.5rem; }
.md-body h3 { font-size: 1.25rem; }
.md-body table { display: table; }
}
`;
// ─── Toolbar button ───────────────────────────────────────────────────────────
function ToolbarBtn({ title, onClick, children, active }) {
return (
<button
type="button"
title={title}
onMouseDown={(e) => { e.preventDefault(); onClick(); }}
className={cn(
"h-7 w-7 flex items-center justify-center rounded text-muted-foreground shrink-0",
"hover:bg-accent hover:text-accent-foreground transition-colors",
active && "bg-accent text-accent-foreground"
)}
>
{children}
</button>
);
}
function Divider() {
return <span className="w-px h-4 bg-border mx-0.5 shrink-0" />;
}
// ─── MarkdownBlock ────────────────────────────────────────────────────────────
export function MarkdownBlock({ content, onUpdate }) {
const [preview, setPreview] = useState(false);
const textareaRef = useRef(null);
const body = content.body ?? "";
// Insert markdown syntax at cursor, wrapping selection when applicable
const insert = (before, after = "", placeholder = "") => {
const el = textareaRef.current;
if (!el) return;
el.focus();
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = body.slice(start, end) || placeholder;
const next = body.slice(0, start) + before + selected + after + body.slice(end);
onUpdate({ ...content, body: next });
// Restore cursor after React re-render
requestAnimationFrame(() => {
el.focus();
const cursor = start + before.length + selected.length + after.length;
el.setSelectionRange(cursor, cursor);
});
};
const insertLine = (prefix) => {
const el = textareaRef.current;
if (!el) return;
el.focus();
const start = el.selectionStart;
const lineStart = body.lastIndexOf("\n", start - 1) + 1;
const next = body.slice(0, lineStart) + prefix + body.slice(lineStart);
onUpdate({ ...content, body: next });
requestAnimationFrame(() => {
el.focus();
el.setSelectionRange(start + prefix.length, start + prefix.length);
});
};
return (
<div className="space-y-1.5">
<Label>Markdown Content</Label>
<div className="border rounded-md overflow-hidden focus-within:ring-2 focus-within:ring-ring">
{/* ── Toolbar ── */}
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b bg-muted/40">
<ToolbarBtn title="Bold" onClick={() => insert("**", "**", "bold text")}>
<Bold className="h-3.5 w-3.5" />
</ToolbarBtn>
<ToolbarBtn title="Italic" onClick={() => insert("*", "*", "italic text")}>
<Italic className="h-3.5 w-3.5" />
</ToolbarBtn>
<Divider />
<ToolbarBtn title="Heading 2" onClick={() => insertLine("## ")}>
<Heading2 className="h-3.5 w-3.5" />
</ToolbarBtn>
<Divider />
<ToolbarBtn title="Inline code" onClick={() => insert("`", "`", "code")}>
<Code className="h-3.5 w-3.5" />
</ToolbarBtn>
<ToolbarBtn title="Code block" onClick={() => insert("```\n", "\n```", "your code here")}>
<Code2 className="h-3.5 w-3.5" />
</ToolbarBtn>
<Divider />
<ToolbarBtn title="Link" onClick={() => insert("[", "](url)", "link text")}>
<Link2 className="h-3.5 w-3.5" />
</ToolbarBtn>
<Divider />
<ToolbarBtn title="Bullet list" onClick={() => insertLine("- ")}>
<List className="h-3.5 w-3.5" />
</ToolbarBtn>
<ToolbarBtn title="Numbered list" onClick={() => insertLine("1. ")}>
<ListOrdered className="h-3.5 w-3.5" />
</ToolbarBtn>
<ToolbarBtn title="Blockquote" onClick={() => insertLine("> ")}>
<Quote className="h-3.5 w-3.5" />
</ToolbarBtn>
<ToolbarBtn title="Horizontal rule" onClick={() => insert("\n---\n", "", "")}>
<Minus className="h-3.5 w-3.5" />
</ToolbarBtn>
{/* Spacer */}
<div className="flex-1" />
{/* Preview toggle */}
<Divider />
<ToolbarBtn
title={preview ? "Edit" : "Preview"}
active={preview}
onClick={() => setPreview((p) => !p)}
>
{preview ? <Pencil className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</ToolbarBtn>
</div>
{/* ── Edit / Preview ── */}
{preview ? (
<div className="min-h-[180px] px-3 py-3">
<style>{MARKDOWN_STYLES}</style>
{body.trim() ? (
<div className="md-body text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
</div>
) : (
<p className="text-xs text-muted-foreground italic">Nothing to preview yet.</p>
)}
</div>
) : (
<textarea
ref={textareaRef}
value={body}
onChange={(e) => onUpdate({ ...content, body: e.target.value })}
placeholder={"# Heading\n\nWrite **markdown** here...\n\n- List item\n- Another item\n\n```js\nconsole.log('hello')\n```"}
className="w-full min-h-[180px] resize-y px-3 py-2 text-sm font-mono focus:outline-none bg-background"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
)}
</div>
</div>
);
}
@@ -5,10 +5,10 @@ import {
Bold, Italic, Underline,
AlignLeft, AlignCenter, AlignRight, AlignJustify,
List, ListOrdered, Indent, Outdent,
Link2, FileText, Table,
Link2, FileText, Table, Code, Code2,
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../AssetPickerSheet";
import { AssetPickerSheet } from "../../AssetPickerSheet";
import { cn } from "@/lib/utils";
// ─── Toolbar button ───────────────────────────────────────────────────────────
@@ -48,60 +48,107 @@ const FORMAT_OPTIONS = [
// ─── Shared styles ────────────────────────────────────────────────────────────
export const WYSIWYG_STYLES = `
.wysiwyg-editor { line-height: 1.7; }
/* ── Base (mobile) ────────────────────────────────────────────────────── */
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.875rem; font-weight: 700; margin: 1.25rem 0 0.5rem; line-height: 1.2; }
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.5rem; font-weight: 600; margin: 1.25rem 0 0.5rem; line-height: 1.3; }
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.25rem; font-weight: 600; margin: 1.25rem 0 0.5rem; line-height: 1.4; }
.wysiwyg-editor { line-height: 1.6; }
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.375rem; font-weight: 700; margin: 1rem 0 0.4rem; line-height: 1.2; }
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.125rem; font-weight: 600; margin: 1rem 0 0.4rem; line-height: 1.3; }
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.4rem; line-height: 1.4; }
.wysiwyg-editor p,
.wysiwyg-preview p {
margin: 0 0 0.85rem 0;
text-align: justify;
line-height: 1.75;
margin: 0 0 0.75rem 0;
text-align: left;
line-height: 1.65;
font-size: 0.9375rem;
}
.wysiwyg-editor ul, .wysiwyg-preview ul { list-style: disc; padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
.wysiwyg-editor ol, .wysiwyg-preview ol { list-style: decimal; padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
.wysiwyg-editor ul, .wysiwyg-preview ul { list-style: disc; padding-left: 1.1rem; margin: 0.4rem 0 0.75rem; }
.wysiwyg-editor ol, .wysiwyg-preview ol { list-style: decimal; padding-left: 1.1rem; margin: 0.4rem 0 0.75rem; }
.wysiwyg-editor li, .wysiwyg-preview li {
margin-bottom: 0.4rem;
line-height: 1.75;
text-align: justify;
margin-bottom: 0.3rem;
line-height: 1.65;
font-size: 0.9375rem;
}
.wysiwyg-editor a, .wysiwyg-preview a { color: hsl(var(--primary)); text-decoration: underline; }
/* Inline code */
.wysiwyg-editor code, .wysiwyg-preview code {
font-family: 'Fira Code', 'Cascadia Code', 'Courier New', Courier, monospace;
font-size: 0.875em;
background: hsl(var(--muted));
color: hsl(var(--foreground));
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
border: 1px solid hsl(var(--border));
white-space: pre-wrap;
word-break: break-word;
}
/* Code block */
.wysiwyg-editor pre, .wysiwyg-preview pre {
background: hsl(220 13% 12%);
color: hsl(220 14% 88%);
border-radius: 0.5rem;
padding: 0.875rem 1rem;
overflow-x: auto;
margin: 0.75rem 0;
border: 1px solid hsl(220 13% 22%);
-webkit-overflow-scrolling: touch;
}
.wysiwyg-editor pre code, .wysiwyg-preview pre code {
background: none;
border: none;
padding: 0;
font-size: 0.875rem;
color: inherit;
white-space: pre;
word-break: normal;
}
.wysiwyg-editor a.doc-link,
.wysiwyg-preview a.doc-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.1rem 0.5rem;
gap: 0.25rem;
padding: 0.1rem 0.4rem;
border-radius: 0.375rem;
background: hsl(var(--muted));
color: hsl(var(--foreground));
font-size: 0.8125rem;
font-size: 0.75rem;
text-decoration: none;
border: 1px solid hsl(var(--border));
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wysiwyg-editor a.doc-link::before,
.wysiwyg-preview a.doc-link::before {
content: "📄";
font-size: 0.75rem;
font-size: 0.7rem;
flex-shrink: 0;
}
/* Table — scrollable on mobile */
.wysiwyg-editor table,
.wysiwyg-preview table {
display: block;
width: 100%;
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-collapse: separate;
border-spacing: 0;
margin: 0.75rem 0;
font-size: 0.875rem;
table-layout: fixed;
margin: 0.6rem 0;
font-size: 0.75rem;
table-layout: auto;
border: 1.5px solid #cbd5e1;
border-radius: 0.375rem;
overflow: hidden;
}
.wysiwyg-editor th,
@@ -110,23 +157,21 @@ export const WYSIWYG_STYLES = `
color: #1e293b;
font-weight: 600;
text-align: left;
padding: 0.5rem 0.75rem;
word-break: break-word;
padding: 0.35rem 0.5rem;
white-space: nowrap;
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
}
.wysiwyg-editor td,
.wysiwyg-preview td {
padding: 0.45rem 0.75rem;
padding: 0.35rem 0.5rem;
vertical-align: top;
word-break: break-word;
min-width: 2rem;
min-width: 4rem;
box-shadow: inset -1.5px -1.5px 0 #cbd5e1, inset 1.5px 1.5px 0 #cbd5e1;
}
.wysiwyg-preview tr:nth-child(even) td {
background: #f8fafc;
}
.wysiwyg-preview tr:nth-child(even) td { background: #f8fafc; }
.wysiwyg-editor td:focus,
.wysiwyg-editor th:focus {
@@ -134,6 +179,112 @@ export const WYSIWYG_STYLES = `
outline-offset: -2px;
background: hsl(var(--accent) / 0.2);
}
/* ── Phone (≥ 320px) ─────────────────────────────────────────────────── */
@media (min-width: 320px) {
.wysiwyg-editor { line-height: 1.7; }
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.625rem; margin: 1.1rem 0 0.45rem; }
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.3rem; margin: 1.1rem 0 0.45rem; }
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.125rem; margin: 1.1rem 0 0.45rem; }
.wysiwyg-editor p,
.wysiwyg-preview p {
font-size: 1rem;
line-height: 1.7;
text-align: justify;
margin: 0 0 0.8rem 0;
}
.wysiwyg-editor ul, .wysiwyg-preview ul { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; }
.wysiwyg-editor ol, .wysiwyg-preview ol { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; }
.wysiwyg-editor li, .wysiwyg-preview li { font-size: 1rem; line-height: 1.7; margin-bottom: 0.35rem; }
.wysiwyg-editor a.doc-link,
.wysiwyg-preview a.doc-link { font-size: 0.8rem; padding: 0.1rem 0.45rem; gap: 0.28rem; }
.wysiwyg-editor table,
.wysiwyg-preview table { font-size: 0.8125rem; margin: 0.7rem 0; }
.wysiwyg-editor th,
.wysiwyg-preview th { padding: 0.45rem 0.65rem; }
.wysiwyg-editor td,
.wysiwyg-preview td { padding: 0.4rem 0.65rem; min-width: 5rem; }
}
/* ── Tablet (≥ 640px) ─────────────────────────────────────────────────── */
@media (min-width: 640px) {
.wysiwyg-editor { line-height: 1.7; }
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.625rem; margin: 1.1rem 0 0.45rem; }
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.3rem; margin: 1.1rem 0 0.45rem; }
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.125rem; margin: 1.1rem 0 0.45rem; }
.wysiwyg-editor p,
.wysiwyg-preview p {
font-size: 1rem;
line-height: 1.7;
text-align: justify;
margin: 0 0 0.8rem 0;
}
.wysiwyg-editor ul, .wysiwyg-preview ul { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; }
.wysiwyg-editor ol, .wysiwyg-preview ol { padding-left: 1.35rem; margin: 0.45rem 0 0.8rem; }
.wysiwyg-editor li, .wysiwyg-preview li { font-size: 1rem; line-height: 1.7; margin-bottom: 0.35rem; }
.wysiwyg-editor a.doc-link,
.wysiwyg-preview a.doc-link { font-size: 0.8rem; padding: 0.1rem 0.45rem; gap: 0.28rem; }
.wysiwyg-editor table,
.wysiwyg-preview table { font-size: 0.8125rem; margin: 0.7rem 0; }
.wysiwyg-editor th,
.wysiwyg-preview th { padding: 0.45rem 0.65rem; }
.wysiwyg-editor td,
.wysiwyg-preview td { padding: 0.4rem 0.65rem; min-width: 5rem; }
}
/* ── Desktop (≥ 1024px) ───────────────────────────────────────────────── */
@media (min-width: 1024px) {
.wysiwyg-editor h1, .wysiwyg-preview h1 { font-size: 1.875rem; margin: 1.25rem 0 0.5rem; }
.wysiwyg-editor h2, .wysiwyg-preview h2 { font-size: 1.5rem; margin: 1.25rem 0 0.5rem; }
.wysiwyg-editor h3, .wysiwyg-preview h3 { font-size: 1.25rem; margin: 1.25rem 0 0.5rem; }
.wysiwyg-editor p,
.wysiwyg-preview p { font-size: 1.08rem; line-height: 1.75; margin: 0 0 0.85rem 0; }
.wysiwyg-editor ul, .wysiwyg-preview ul { padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
.wysiwyg-editor ol, .wysiwyg-preview ol { padding-left: 1.5rem; margin: 0.5rem 0 0.85rem; }
.wysiwyg-editor li, .wysiwyg-preview li { font-size: 1.08em; line-height: 1.75; margin-bottom: 0.4rem; }
.wysiwyg-editor a.doc-link,
.wysiwyg-preview a.doc-link { font-size: 0.8125rem; padding: 0.1rem 0.5rem; gap: 0.3rem; }
.wysiwyg-editor table,
.wysiwyg-preview table {
display: table;
font-size: 0.875rem;
margin: 0.75rem 0;
table-layout: fixed;
}
.wysiwyg-editor th,
.wysiwyg-preview th { padding: 0.5rem 0.75rem; }
.wysiwyg-editor td,
.wysiwyg-preview td { padding: 0.45rem 0.75rem; min-width: 2rem; }
}
`;
// ─── Table picker popover ─────────────────────────────────────────────────────
@@ -192,13 +343,13 @@ function TablePicker({ onInsert, onClose }) {
// ─── RichTextEditor ───────────────────────────────────────────────────────────
export function RichTextEditor({ blockId, value, onChange }) {
const editorRef = useRef(null);
export function RichTextEditor({ blockId, value, onChange, readOnly = false }) {
const editorRef = useRef(null);
const initializedFor = useRef(null);
const savedRange = useRef(null);
const savedRange = useRef(null);
const tableButtonRef = useRef(null);
const [docPickerOpen, setDocPickerOpen] = useState(false);
const [docPickerOpen, setDocPickerOpen] = useState(false);
const [tablePickerOpen, setTablePickerOpen] = useState(false);
// ── Seed innerHTML exactly once per blockId ────────────────────────────────
@@ -252,7 +403,7 @@ export function RichTextEditor({ blockId, value, onChange }) {
const handleDocSelect = (asset) => {
setDocPickerOpen(false);
const url = asset.file_url;
const url = asset.file_url;
const label = asset.display_name ?? "Document";
editorRef.current?.focus();
@@ -268,6 +419,39 @@ export function RichTextEditor({ blockId, value, onChange }) {
savedRange.current = null;
};
// ── Inline code toggle ────────────────────────────────────────────────────
// Wraps the selected text in <code>. If the cursor is already inside a
// <code> element, unwraps it instead.
const toggleInlineCode = () => {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
const ancestor = range.commonAncestorContainer;
const codeParent = (ancestor.nodeType === 3 ? ancestor.parentElement : ancestor)?.closest("code");
if (codeParent) {
const text = document.createTextNode(codeParent.textContent ?? "");
codeParent.replaceWith(text);
onChange(editorRef.current.innerHTML);
} else {
const text = sel.toString();
if (!text) return;
exec("insertHTML", `<code>${text}</code>`);
onChange(editorRef.current.innerHTML);
}
};
// ── Code block insertion ───────────────────────────────────────────────────
const insertCodeBlock = () => {
editorRef.current?.focus();
const sel = window.getSelection();
const selectedText = sel?.toString() || "// your code here";
exec("insertHTML", `<pre><code>${selectedText}</code></pre><p><br></p>`);
onChange(editorRef.current.innerHTML);
};
// ── Table insertion ────────────────────────────────────────────────────────
// Builds a <table> with a header row (th) + (rows-1) data rows.
// Each cell is contenteditable (inherited from the editor).
@@ -322,21 +506,21 @@ export function RichTextEditor({ blockId, value, onChange }) {
const GROUPS = [
[
{ cmd: "bold", Icon: Bold, title: "Bold" },
{ cmd: "italic", Icon: Italic, title: "Italic" },
{ cmd: "underline", Icon: Underline, title: "Underline" },
{ cmd: "bold", Icon: Bold, title: "Bold" },
{ cmd: "italic", Icon: Italic, title: "Italic" },
{ cmd: "underline", Icon: Underline, title: "Underline" },
],
[
{ cmd: "justifyLeft", Icon: AlignLeft, title: "Align left" },
{ cmd: "justifyCenter", Icon: AlignCenter, title: "Align center" },
{ cmd: "justifyRight", Icon: AlignRight, title: "Align right" },
{ cmd: "justifyFull", Icon: AlignJustify, title: "Justify" },
{ cmd: "justifyLeft", Icon: AlignLeft, title: "Align left" },
{ cmd: "justifyCenter", Icon: AlignCenter, title: "Align center" },
{ cmd: "justifyRight", Icon: AlignRight, title: "Align right" },
{ cmd: "justifyFull", Icon: AlignJustify, title: "Justify" },
],
[
{ cmd: "insertUnorderedList", Icon: List, title: "Bullet list" },
{ cmd: "insertOrderedList", Icon: ListOrdered, title: "Numbered list" },
{ cmd: "indent", Icon: Indent, title: "Indent" },
{ cmd: "outdent", Icon: Outdent, title: "Outdent" },
{ cmd: "insertUnorderedList", Icon: List, title: "Bullet list" },
{ cmd: "insertOrderedList", Icon: ListOrdered, title: "Numbered list" },
{ cmd: "indent", Icon: Indent, title: "Indent" },
{ cmd: "outdent", Icon: Outdent, title: "Outdent" },
],
];
@@ -408,6 +592,24 @@ export function RichTextEditor({ blockId, value, onChange }) {
<Table className="h-3.5 w-3.5" />
</span>
</ToolbarBtn>
<Divider />
{/* Inline code */}
<ToolbarBtn
title="Inline code"
onMouseDown={(e) => { e.preventDefault(); toggleInlineCode(); }}
>
<Code className="h-3.5 w-3.5" />
</ToolbarBtn>
{/* Code block */}
<ToolbarBtn
title="Code block"
onMouseDown={(e) => { e.preventDefault(); insertCodeBlock(); }}
>
<Code2 className="h-3.5 w-3.5" />
</ToolbarBtn>
</div>
{/* ── Editable area ── */}
@@ -431,27 +633,37 @@ export function RichTextEditor({ blockId, value, onChange }) {
)}
{/* Document asset picker */}
<AssetPickerSheet
open={docPickerOpen}
onOpenChange={setDocPickerOpen}
fileType="document"
onSelect={handleDocSelect}
/>
{!readOnly && (
<AssetPickerSheet
open={docPickerOpen}
onOpenChange={setDocPickerOpen}
fileType="document"
onSelect={handleDocSelect}
/>
)}
</>
);
}
// ─── TextBlock ────────────────────────────────────────────────────────────────
export function TextBlock({ content, onUpdate, blockId }) {
return (
<div className="space-y-1.5">
<Label>Content</Label>
<RichTextEditor
blockId={blockId}
value={content.body ?? ""}
onChange={(html) => onUpdate({ ...content, body: html })}
/>
</div>
);
export function TextBlock({ content, onUpdate, blockId, readOnly = false }) {
if (readOnly) {
return (
<div
className="wysiwyg-preview text-sm"
dangerouslySetInnerHTML={{ __html: content.body ?? "" }}
/>
);
}
return (
<div className="space-y-1.5">
<Label>Content</Label>
<RichTextEditor
blockId={blockId}
value={content.body ?? ""}
onChange={(html) => onUpdate({ ...content, body: html })}
/>
</div>
);
}
@@ -12,9 +12,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
import { AssetPickerSheet } from "../AssetPickerSheet";
import { AssetPickerSheet } from "../../AssetPickerSheet";
export function TextImageBlock({ content, onUpdate, blockId }) {
export function TextImageBlock({ content, onUpdate, blockId, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
return (
@@ -92,16 +92,18 @@ export function TextImageBlock({ content, onUpdate, blockId }) {
</div>
</div>
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
url: asset.file_url,
})}
/>
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="image"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
url: asset.file_url,
})}
/>
)}
</div>
);
}
@@ -11,9 +11,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
import { AssetPickerSheet } from "../AssetPickerSheet";
import { AssetPickerSheet } from "../../AssetPickerSheet";
export function TextVideoBlock({ content, onUpdate, blockId }) {
export function TextVideoBlock({ content, onUpdate, blockId, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
const thumb = content.thumbnail_url ?? null;
@@ -93,17 +93,19 @@ export function TextVideoBlock({ content, onUpdate, blockId }) {
</div>
</div>
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="video"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
url: asset.file_url,
thumbnail_url: asset.thumbnail_url ?? null,
})}
/>
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="video"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
url: asset.file_url,
thumbnail_url: asset.thumbnail_url ?? null,
})}
/>
)}
</div>
);
}
@@ -0,0 +1,280 @@
import { useRef, useState, useEffect, useCallback } from "react";
import {
Play,
Pause,
SkipBack,
Volume2,
VolumeX,
Maximize2,
VideoIcon,
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../../AssetPickerSheet";
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtTime = (s) => {
if (!s || isNaN(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
};
// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
export function VideoBlock({ content, onUpdate, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
const vidRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [totalDuration, setTotalDuration] = useState(0);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [overlayVisible,setOverlayVisible]= useState(true);
// Reset player when video changes
useEffect(() => {
setPlaying(false);
setProgress(0);
setCurrentTime(0);
setTotalDuration(0);
setOverlayVisible(true);
}, [content.url]);
// ── Video event listeners ─────────────────────────────────────────────────
useEffect(() => {
const v = vidRef.current;
if (!v) return;
const onTimeUpdate = () => {
setCurrentTime(v.currentTime);
if (v.duration) setProgress((v.currentTime / v.duration) * 100);
};
const onLoaded = () => setTotalDuration(v.duration);
const onEnded = () => { setPlaying(false); setOverlayVisible(true); };
v.addEventListener("timeupdate", onTimeUpdate);
v.addEventListener("loadedmetadata", onLoaded);
v.addEventListener("ended", onEnded);
return () => {
v.removeEventListener("timeupdate", onTimeUpdate);
v.removeEventListener("loadedmetadata", onLoaded);
v.removeEventListener("ended", onEnded);
};
}, [content.url]);
// ── Controls ──────────────────────────────────────────────────────────────
const togglePlay = useCallback(() => {
const v = vidRef.current;
if (!v) return;
if (v.paused) { v.play(); setPlaying(true); setOverlayVisible(false); }
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
}, []);
const restart = () => {
const v = vidRef.current;
if (!v) return;
v.currentTime = 0;
v.pause();
setPlaying(false);
setOverlayVisible(true);
};
const handleSeek = (e) => {
const v = vidRef.current;
if (!v || !v.duration) return;
v.currentTime = (parseFloat(e.target.value) / 100) * v.duration;
};
const handleVolumeChange = (e) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (vidRef.current) { vidRef.current.volume = val; vidRef.current.muted = val === 0; }
setMuted(val === 0);
};
const toggleMute = () => {
const v = vidRef.current;
if (!v) return;
v.muted = !v.muted;
setMuted(v.muted);
};
const toggleFullscreen = () => {
const el = document.getElementById("video-block-wrap");
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen();
else el.requestFullscreen?.();
};
// ── Asset picker handler ──────────────────────────────────────────────────
//
// Saves all metadata needed by the client VideoBlock at render time.
// Clean object — no ...content spread so stale data never carries over.
//
// Fields saved:
// asset_id — used by client for the secure token flow
// url — used by admin player (direct src); ignored by client for S3
// thumbnail_url — poster image for the video player
// title — display name (shown in any title-aware blocks)
// tag — file extension badge e.g. "MP4"
//
const handleSelect = (asset) => {
onUpdate({
asset_id: asset.asset_id,
url: asset.file_url,
thumbnail_url: asset.thumbnail_url ?? null,
title: asset.display_name,
tag: asset.extension?.toUpperCase() ?? "",
});
setPlaying(false);
setProgress(0);
setCurrentTime(0);
setTotalDuration(0);
setOverlayVisible(true);
};
// ─────────────────────────────────────────────────────────────────────────
return (
<div className="space-y-3">
{!readOnly && <Label>Video</Label>}
{content.url ? (
<div className="rounded-lg overflow-hidden bg-card">
{/* ── Video area ── */}
<div
id="video-block-wrap"
className="relative w-full bg-black cursor-pointer group"
style={{ aspectRatio: "16/9" }}
onClick={togglePlay}
>
<video
ref={vidRef}
src={content.url}
poster={content.thumbnail_url ?? undefined}
preload="metadata"
playsInline
className="w-full h-full object-cover"
/>
{/* Play/pause overlay */}
<div
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${overlayVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
style={{ background: "rgba(0,0,0,0.3)" }}
>
<button
aria-label={playing ? "Pause" : "Play"}
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
className="w-12 h-12 rounded-full bg-white/90 hover:bg-white flex items-center justify-center transition-transform hover:scale-105"
>
{playing
? <Pause className="size-4 text-black" />
: <Play className="size-4 text-black ml-0.5" />
}
</button>
</div>
{/* Change video hover hint */}
{!readOnly && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
className="text-xs bg-black/60 hover:bg-black/80 text-white px-2.5 py-1 rounded-md transition-colors"
onClick={(e) => { e.stopPropagation(); setPickerOpen(true); }}
>
Change
</button>
</div>
)}
</div>
{/* ── Player controls ── */}
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-2">
{/* Progress bar */}
<div className="relative h-1 bg-border rounded-full cursor-pointer">
<div
className="h-full bg-foreground rounded-full transition-[width] duration-100"
style={{ width: `${progress}%` }}
/>
<input
type="range" min="0" max="100" step="0.1"
value={progress}
onChange={handleSeek}
aria-label="Seek"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
{/* Button row */}
<div className="flex items-center gap-2">
<button onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} className="text-muted-foreground hover:text-foreground transition-colors">
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
</button>
<button onClick={restart} aria-label="Restart" className="text-muted-foreground hover:text-foreground transition-colors">
<SkipBack className="size-4" />
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
</span>
<div className="flex items-center gap-1.5 ml-auto">
<button onClick={toggleMute} aria-label="Toggle mute" className="text-muted-foreground hover:text-foreground transition-colors">
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
</button>
<input
type="range" min="0" max="1" step="0.05"
value={muted ? 0 : volume}
onChange={handleVolumeChange}
aria-label="Volume"
className="w-16 accent-foreground"
/>
</div>
<button onClick={toggleFullscreen} aria-label="Fullscreen" className="text-muted-foreground hover:text-foreground transition-colors ml-1">
<Maximize2 className="size-4" />
</button>
</div>
</div>
{/* ── Change video footer (admin only) ── */}
{!readOnly && (
<div className="px-3 pb-3">
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full text-sm text-muted-foreground border rounded-md py-1.5 hover:bg-muted transition-colors"
>
Change video
</button>
</div>
)}
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full aspect-video rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
>
<VideoIcon className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Click to select a video</p>
</button>
)}
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="video"
onSelect={handleSelect}
/>
)}
</div>
);
}
@@ -0,0 +1,60 @@
// components/blocks/Banner.jsx
import { Megaphone } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
// Height per size — controls the strip's visual weight, not its width (always full-width).
const SIZE_HEIGHT = {
sm: "h-20",
md: "h-32",
lg: "h-48",
};
// ── Banner ───────────────────────────────────────────────────────────────────
/**
* Generic banner advertisement block.
* Full-width horizontal strip, image-led with optional headline overlay.
* Click anywhere on the banner triggers the first available CTA (or just tracks
* the click if no CTA exists) — banners don't carry their own button row.
*
* Props:
* ad — advertisement object { headline, ctas, image, image_url, advertisement_id }
* size — "sm" | "md" | "lg" (default "md")
* onCtaClick — (ad, cta) => void, called on click. cta may be undefined if the ad has none.
*/
export function Banner({ ad, size, onCtaClick }) {
if (!ad) return null;
const imageSrc = ad.image?.file_url || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const resolvedSize = ad.size || size || "md";
const heightClass = SIZE_HEIGHT[resolvedSize] ?? SIZE_HEIGHT.md;
const handleClick = () => onCtaClick?.(ad, ctas[0]);
return (
<button
type="button"
onClick={handleClick}
className={`relative w-full rounded-lg bg-muted overflow-hidden flex items-center justify-center text-left ${heightClass}`}
>
{imageSrc ? (
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
) : (
<Megaphone className="size-6 text-muted-foreground" />
)}
{ad.headline && (
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-black/0 to-black/0 flex items-end p-4">
<p className="text-white font-medium text-sm sm:text-base">{ad.headline}</p>
</div>
)}
</button>
);
}
// ── BannerSkeleton ───────────────────────────────────────────────────────────
export function BannerSkeleton({ size = "md" }) {
return <Skeleton className={`w-full rounded-lg ${SIZE_HEIGHT[size] ?? SIZE_HEIGHT.md}`} />;
}
@@ -0,0 +1,85 @@
// components/blocks/Hero.jsx
import { Megaphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
// ── Hero ─────────────────────────────────────────────────────────────────────
/**
* Generic hero advertisement block.
* Two-column layout: badge/headline/description/CTAs on the left, image on the right.
* Renders null when no ad is provided — callers should not fall back to placeholder copy.
*
* Props:
* ad — advertisement object { badge_label, headline, description, ctas, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
*/
export function Hero({ ad, onCtaClick }) {
if (!ad) return null;
const imageSrc = ad.image?.file_url || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
return (
<div className="flex xs:flex-col lg:flex-row items-center gap-6">
<div className="flex flex-col gap-3">
{ad.badge_label && (
<Badge variant="outline">
<Megaphone /> {ad.badge_label}
</Badge>
)}
{ad.headline && (
<div className="font-bold text-4xl leading-12">
{ad.headline}
</div>
)}
{ad.description && (
<p className="max-w-lg">
{ad.description}
</p>
)}
{ctas.length > 0 && (
<div className="flex items-center gap-2">
{ctas.map((cta, i) => (
<Button
key={i}
variant={cta.variant === "outline" ? "outline" : "default"}
onClick={() => onCtaClick?.(ad, cta)}
>
{cta.label}
</Button>
))}
</div>
)}
</div>
<div className="rounded-lg bg-muted w-xl aspect-video flex items-center justify-center overflow-hidden">
{imageSrc ? (
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
) : (
<Megaphone className="size-8 text-muted-foreground" />
)}
</div>
</div>
);
}
// ── HeroSkeleton ─────────────────────────────────────────────────────────────
export function HeroSkeleton() {
return (
<div className="flex xs:flex-col lg:flex-row items-center gap-6">
<div className="flex flex-col gap-3 w-full max-w-lg">
<Skeleton className="h-6 w-32 rounded-full" />
<Skeleton className="h-10 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="flex gap-2 pt-1">
<Skeleton className="h-9 w-24" />
<Skeleton className="h-9 w-28" />
</div>
</div>
<Skeleton className="rounded-lg w-xl aspect-video" />
</div>
);
}
@@ -0,0 +1,54 @@
// components/blocks/Popup.jsx
import { Button } from "@/components/ui/button";
import ResponsiveModal from "@/components/generic/ResponsiveModal";
// ── Popup ────────────────────────────────────────────────────────────────────
/**
* Generic popup advertisement block.
* Modal-style placement shown on page load — wraps ResponsiveModal so it gets
* dialog/drawer behavior for free. Caller owns the `open` state (typically set
* to true once an active popup ad resolves from the API).
*
* Props:
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
* open — boolean, modal visibility
* onOpenChange — (open: boolean) => void
* onCtaClick — (ad, cta) => void, called when a footer CTA button is clicked
*/
export function Popup({ ad, open, onOpenChange, onCtaClick }) {
if (!ad) return null;
const imageSrc = ad.image?.file_url || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
return (
<ResponsiveModal
open={open}
onOpenChange={onOpenChange}
title={ad.headline || "Announcement"}
description={ad.description || undefined}
footer={
ctas.length > 0 ? (
<>
{ctas.map((cta, i) => (
<Button
key={i}
variant={cta.variant === "outline" ? "outline" : "default"}
onClick={() => onCtaClick?.(ad, cta)}
>
{cta.label}
</Button>
))}
</>
) : undefined
}
>
{imageSrc && (
<div className="rounded-lg bg-muted aspect-video flex items-center justify-center overflow-hidden">
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
</div>
)}
</ResponsiveModal>
);
}
@@ -0,0 +1,77 @@
// components/blocks/Sidebar.jsx
import { Megaphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
// ── Sidebar ──────────────────────────────────────────────────────────────────
/**
* Generic sidebar advertisement block.
* Compact vertical card — image on top, optional short headline/description and
* a single CTA below. Meant to sit in a narrow column (sidebars, rail layouts),
* not stretch full-width like Hero/Banner.
*
* Props:
* ad — advertisement object { headline, description, ctas, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void, called when the CTA button (or card, if no CTA) is clicked
*/
export function Sidebar({ ad, onCtaClick }) {
if (!ad) return null;
const imageSrc = ad.image?.file_url || ad.image_url || null;
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const primaryCta = ctas[0];
const handleCardClick = () => {
if (!primaryCta) onCtaClick?.(ad, undefined);
};
return (
<div
className="w-full max-w-xs rounded-lg border bg-card overflow-hidden flex flex-col cursor-pointer"
onClick={handleCardClick}
>
<div className="aspect-square bg-muted flex items-center justify-center overflow-hidden">
{imageSrc ? (
<img src={imageSrc} alt={ad.headline || "Advertisement"} className="w-full h-full object-cover" />
) : (
<Megaphone className="size-6 text-muted-foreground" />
)}
</div>
{(ad.headline || ad.description || primaryCta) && (
<div className="p-3 flex flex-col gap-1.5">
{ad.headline && <p className="text-sm font-medium leading-snug">{ad.headline}</p>}
{ad.description && <p className="text-xs text-muted-foreground line-clamp-2">{ad.description}</p>}
{primaryCta && (
<Button
size="sm"
className="mt-1 w-full"
onClick={(e) => {
e.stopPropagation();
onCtaClick?.(ad, primaryCta);
}}
>
{primaryCta.label}
</Button>
)}
</div>
)}
</div>
);
}
// ── SidebarSkeleton ──────────────────────────────────────────────────────────
export function SidebarSkeleton() {
return (
<div className="w-full max-w-xs rounded-lg border bg-card overflow-hidden flex flex-col">
<Skeleton className="aspect-square w-full" />
<div className="p-3 flex flex-col gap-1.5">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-8 w-full mt-1" />
</div>
</div>
);
}
@@ -0,0 +1,329 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
import api from "@/utils/api.util";
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtTime = (s) => {
if (!s || isNaN(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
};
const SPEEDS = [0.5, 0.75, 1, 1.25, 1.5, 2];
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// ─── AudioBlock (Client — secure) ────────────────────────────────────────────
//
// S3/Garage:
// 1. POST /client/media/token → get JWT token
// 2. fetch(streamUrl, { credentials: "include" }) → get raw bytes
// 3. URL.createObjectURL(blob) → blob:http://... URL
// 4. <audio src="blob:..."> → real URL never visible in DOM
//
// Chibisafe:
// → content.url used directly (Chibisafe CDN, no proxy needed)
//
// Direct (legacy):
// → content.url / content.src used directly, no token flow
//
// content shape: { asset_id?, url?, storage_provider?, title?, artist?, tag?, thumbnail? }
export function AudioBlock({ content }) {
const audioRef = useRef(null);
// ── Stream state ──────────────────────────────────────────────────────────
const [blobUrl, setBlobUrl] = useState(null);
const [thumbnailUrl, setThumbnailUrl] = useState(null);
const [fetchLoading, setFetchLoading] = useState(false);
const [fetchError, setFetchError] = useState(false);
// ── Player state ──────────────────────────────────────────────────────────
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [buffered, setBuffered] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [speedIdx, setSpeedIdx] = useState(2); // 1×
const assetId = content?.asset_id;
const storageProvider = content?.storage_provider;
const directUrl = content?.url ?? content?.src ?? null;
const title = content?.title ?? "Audio";
const artist = content?.artist ?? "";
const tag = content?.tag ?? "";
// For S3 assets, thumbnailUrl is set from the token response (presigned URL).
// For other providers, fall back to the raw content.thumbnail value.
const thumbnail = thumbnailUrl ?? content?.thumbnail ?? null;
// ── Resolve stream URL → set as audio src directly ───────────────────────
useEffect(() => {
setBlobUrl(null);
setThumbnailUrl(null);
setFetchError(false);
setPlaying(false);
setCurrentTime(0);
setDuration(0);
// Legacy direct URL — no asset_id
if (!assetId && directUrl) {
setBlobUrl(directUrl);
return;
}
if (!assetId) return;
let cancelled = false;
const load = async () => {
setFetchLoading(true);
try {
// Chibisafe — use raw URL directly (CDN is public, no token needed)
if (storageProvider === "chibisafe") {
if (!directUrl) throw new Error("No URL in content");
if (!cancelled) setBlobUrl(directUrl);
return;
}
// S3 — get token; response also includes presigned thumbnail URL
const { data } = await api.post("/client/media/token", { asset_id: assetId });
if (cancelled) return;
const { token, thumbnail_url } = data?.data ?? {};
if (!token) throw new Error("No token returned");
if (!cancelled) {
setBlobUrl(`${API_BASE}/client/media/stream/${token}`);
if (thumbnail_url) setThumbnailUrl(thumbnail_url);
}
} catch (err) {
if (cancelled) return;
console.error("[AudioBlock] load failed", err);
setFetchError(true);
} finally {
if (!cancelled) setFetchLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [assetId, storageProvider, directUrl]);
// ── Audio events ──────────────────────────────────────────────────────────
const onTimeUpdate = useCallback(() => setCurrentTime(audioRef.current?.currentTime ?? 0), []);
const onLoadedMeta = useCallback(() => setDuration(audioRef.current?.duration ?? 0), []);
const onEnded = useCallback(() => setPlaying(false), []);
const onProgress = useCallback(() => {
const el = audioRef.current;
if (el?.buffered.length && el.duration) {
setBuffered((el.buffered.end(el.buffered.length - 1) / el.duration) * 100);
}
}, []);
// ── Controls ──────────────────────────────────────────────────────────────
const togglePlay = () => {
const el = audioRef.current;
if (!el) return;
if (playing) { el.pause(); setPlaying(false); }
else { el.play(); setPlaying(true); }
};
const seek = (e) => {
const el = audioRef.current;
const bar = e.currentTarget;
const pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth;
el.currentTime = pct * duration;
};
const skip = (secs) => {
const el = audioRef.current;
if (!el) return;
el.currentTime = Math.min(Math.max(0, el.currentTime + secs), duration);
};
const handleVolume = (e) => {
const v = parseFloat(e.target.value);
setVolume(v);
if (audioRef.current) audioRef.current.volume = v;
setMuted(v === 0);
};
const toggleMute = () => {
const el = audioRef.current;
if (!el) return;
el.muted = !muted;
setMuted(!muted);
};
const cycleSpeed = () => {
const next = (speedIdx + 1) % SPEEDS.length;
setSpeedIdx(next);
if (audioRef.current) audioRef.current.playbackRate = SPEEDS[next];
};
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
// ── States ────────────────────────────────────────────────────────────────
if (fetchLoading) {
return (
<div className="w-full rounded-xl border border-border bg-card flex items-center justify-center h-28">
<div className="w-5 h-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground animate-spin" />
</div>
);
}
if (fetchError) {
return (
<div className="w-full rounded-xl border border-border bg-card flex items-center justify-center h-24 gap-2 text-muted-foreground text-sm">
<Music2 className="size-4" /> Audio unavailable.
</div>
);
}
if (!blobUrl) return null;
// ─────────────────────────────────────────────────────────────────────────
return (
<div className="w-full rounded-xl overflow-hidden border border-border bg-card text-card-foreground shadow-sm">
<audio
ref={audioRef}
src={blobUrl}
onTimeUpdate={onTimeUpdate}
onLoadedMetadata={onLoadedMeta}
onEnded={onEnded}
onProgress={onProgress}
preload="auto"
/>
{/* ── Header ── */}
<div className="relative overflow-hidden">
<div className="xs:opacity-0 lg:opacity-100 select-none absolute top-4 right-5 z-20">
<img src="/philpro-white-single.png" alt="Logo" className="h-6 w-auto" />
</div>
{thumbnail ? (
<div
className="absolute inset-0 scale-110"
style={{
backgroundImage: `url(${thumbnail})`,
backgroundSize: "cover",
backgroundPosition: "center",
filter: "blur(24px) brightness(0.35)",
}}
/>
) : (
<div className="absolute inset-0 bg-muted" />
)}
{/* Mobile */}
<div className="relative z-10 flex flex-col gap-4 pb-6 text-white sm:hidden">
<div className="w-full px-4 pt-4">
<div className="w-full h-72 aspect-square rounded-lg overflow-hidden bg-black/30 shadow-xl dark:border">
{thumbnail ? (
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<Music2 className="w-10 h-10 text-white/30" />
</div>
)}
</div>
</div>
<div className="flex flex-col gap-1 px-4">
{tag && (
<div className="text-xs font-semibold rounded-full uppercase px-3 py-1 bg-card text-card-foreground border w-fit mb-1">
{tag}
</div>
)}
<p className="text-lg font-semibold leading-tight">{title}</p>
{artist && <p className="text-sm text-white/60">{artist}</p>}
</div>
</div>
{/* Desktop */}
<div className="relative z-10 hidden sm:flex items-center gap-4 p-4 text-white">
<div className="shrink-0 w-48 h-48 rounded-md overflow-hidden bg-black/25">
{thumbnail ? (
<img src={thumbnail} alt={title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<Music2 className="w-7 h-7 text-white/30" />
</div>
)}
</div>
<div className="flex flex-col gap-1 min-w-0">
{tag && (
<div className="text-xs font-semibold rounded-full uppercase px-3 py-1 bg-card text-card-foreground border w-fit">
{tag}
</div>
)}
<p className="w-sm line-clamp-3 text-lg font-semibold leading-tight">{title}</p>
{artist && <p className="text-sm text-white/60 truncate">{artist}</p>}
</div>
</div>
</div>
{/* ── Controls ── */}
<div className="px-4 pb-4 pt-3 space-y-3">
<div className="flex items-center gap-2.5">
<span className="text-xs tabular-nums text-card-foreground w-8 shrink-0">
{fmtTime(currentTime)}
</span>
<div
className="flex-1 h-1.5 rounded-full bg-muted cursor-pointer relative group"
onClick={seek}
role="slider"
aria-label="Seek"
aria-valuenow={Math.round(progress)}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="absolute inset-y-0 left-0 rounded-full bg-muted-foreground/25 transition-[width] duration-300" style={{ width: `${buffered}%` }} />
<div className="h-full rounded-full bg-primary transition-all relative" style={{ width: `${progress}%` }} />
<div className="absolute top-1/2 w-3 h-3 rounded-full bg-primary opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%`, transform: "translate(-50%, -50%)" }} />
</div>
<span className="text-xs tabular-nums text-card-foreground w-8 shrink-0 text-right">
{fmtTime(duration)}
</span>
</div>
<div className="grid grid-cols-3 items-center">
{/* Left — volume */}
<div className="flex items-center gap-1.5">
<button onClick={toggleMute} aria-label={muted ? "Unmute" : "Mute"} className="w-7 h-7 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
{muted || volume === 0 ? <VolumeOff className="size-4" /> : <Volume2 className="size-4" />}
</button>
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolume} aria-label="Volume" className="w-16 h-1.5" />
</div>
{/* Center — play controls */}
<div className="flex items-center justify-center gap-2">
<button onClick={() => skip(-10)} aria-label="Rewind 10 seconds" className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
<RotateCcw className="size-4" />
</button>
<button onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} className="p-3 rounded-full flex items-center justify-center bg-primary text-primary-foreground hover:opacity-80 transition-opacity active:scale-95 shadow-md">
{playing ? <Pause className="xs:size-4 lg:size-5" /> : <Play className="xs:size-4 lg:size-5" />}
</button>
<button onClick={() => skip(10)} aria-label="Forward 10 seconds" className="p-2 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
<RotateCw className="size-4" />
</button>
</div>
{/* Right — speed */}
<div className="flex items-center justify-end gap-1">
<button onClick={cycleSpeed} aria-label={`Playback speed ${SPEEDS[speedIdx]}x`} className="h-7 px-2 rounded text-sm font-medium text-foreground hover:bg-muted transition-colors tabular-nums">
{SPEEDS[speedIdx]}x
</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,50 @@
import { useState } from "react";
import { Copy, Check } from "lucide-react";
export function CodeBlock({ content }) {
const [copied, setCopied] = useState(false);
const code = content.code ?? "";
const language = content.language ?? "text";
const handleCopy = () => {
navigator.clipboard.writeText(code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
if (!code) {
return (
<div className="rounded-lg border border-dashed bg-muted/20 p-4 text-xs text-muted-foreground italic">
Empty code block
</div>
);
}
return (
<div className="rounded-lg overflow-hidden border border-zinc-700 dark:border-zinc-600 bg-zinc-950 text-zinc-100 my-2">
{/* ── Header bar ── */}
<div className="flex items-center justify-between px-4 py-2 bg-zinc-900 border-b border-zinc-700">
<span className="text-[11px] font-mono text-zinc-400 uppercase tracking-widest select-none">
{language}
</span>
<button
onClick={handleCopy}
className="flex items-center gap-1.5 text-xs text-zinc-400 hover:text-zinc-100 transition-colors"
>
{copied
? <Check className="size-3.5 text-emerald-400" />
: <Copy className="size-3.5" />
}
<span>{copied ? "Copied!" : "Copy"}</span>
</button>
</div>
{/* ── Code area ── */}
<pre className="overflow-x-auto p-4 text-sm leading-relaxed" style={{ margin: 0, background: "transparent" }}>
<code className="font-mono whitespace-pre">{code}</code>
</pre>
</div>
);
}
@@ -0,0 +1,15 @@
import { ImageIcon } from "lucide-react";
import { ZoomableImage } from "@/modules/admin/components/courses/LessonsPreview";
export function ImageBlock({ content }) {
if (!content.url) {
return (
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
<ImageIcon className="h-4 w-4" />
No image
</div>
);
}
return <ZoomableImage url={content.url} alt={content.alt} />;
}
@@ -0,0 +1,24 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { MARKDOWN_STYLES } from "@/components/generic/Blocks/Admin/MarkdownBlock";
export function MarkdownBlock({ content }) {
const body = content.body ?? "";
if (!body.trim()) {
return (
<div className="text-xs text-muted-foreground italic py-2">
Empty markdown block
</div>
);
}
return (
<>
<style>{MARKDOWN_STYLES}</style>
<div className="md-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
</div>
</>
);
}
@@ -0,0 +1,16 @@
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
export function TextBlock({ content }) {
if (!content.body) {
return <div className="text-xs text-muted-foreground italic py-2">Empty text block</div>;
}
return (
<>
<style>{WYSIWYG_STYLES}</style>
<div
className="wysiwyg-preview text-sm"
dangerouslySetInnerHTML={{ __html: content.body }}
/>
</>
);
}
@@ -0,0 +1,19 @@
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
import { ImageBlock } from "./ImageBlock";
export function TextImageBlock({ content }) {
const imgLeft = content.image_position === "left";
return (
<>
<style>{WYSIWYG_STYLES}</style>
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
{imgLeft && <ImageBlock content={content} />}
<div
className="wysiwyg-preview text-sm w-full"
dangerouslySetInnerHTML={{ __html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>" }}
/>
{!imgLeft && <ImageBlock content={content} />}
</div>
</>
);
}
@@ -0,0 +1,21 @@
import { WYSIWYG_STYLES } from "@/components/generic/Blocks/Admin/TextBlock";
import { VideoBlock } from "./VideoBlock";
export function TextVideoBlock({ content }) {
const vidLeft = content.video_position === "left";
return (
<>
<style>{WYSIWYG_STYLES}</style>
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
{vidLeft && <VideoBlock content={content} />}
<div
className="wysiwyg-preview text-sm w-full"
dangerouslySetInnerHTML={{
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
}}
/>
{!vidLeft && <VideoBlock content={content} />}
</div>
</>
);
}
@@ -0,0 +1,621 @@
import { useRef, useState, useEffect, useCallback } from "react";
import {
Play, Pause, SkipBack, Volume2, VolumeX, Maximize2, Minimize2, Settings, VideoIcon,
Volume1, SkipForward, Gauge, X, RotateCcw,
} from "lucide-react";
import {
Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,
} from "@/components/ui/tooltip";
import { ChevronLeft, ChevronRight } from "lucide-react";
import api from "@/utils/api.util";
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtTime = (s) => {
if (!s || isNaN(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
};
const PLAYBACK_SPEEDS = ["0.5", "0.75", "Normal", "1.25", "1.5", "2"];
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
// ─── Tooltip control button ───────────────────────────────────────────────────
function CtrlBtn({ label, onClick, children, className = "" }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={label}
onClick={onClick}
className={`text-white/80 hover:text-white transition-colors flex items-center justify-center ${className}`}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-sm">{label}</TooltipContent>
</Tooltip>
);
}
// ─── Settings panel ───────────────────────────────────────────────────────────
function SettingsPanel({ speed, onSpeed, onClose }) {
const [tab, setTab] = useState(null);
const Row = ({ icon: Icon, label, value, onClick }) => (
<button
type="button"
onClick={onClick}
className="w-full flex items-center justify-between px-4 py-2.5 hover:bg-white/10 transition-colors text-sm"
>
<span className="flex items-center gap-2.5 text-white/90">
<Icon className="size-4 text-white/50" />
{label}
</span>
<div className="text-white/50 flex items-center gap-2">
<p>{value}</p><ChevronRight className="size-4" />
</div>
</button>
);
const OptionList = ({ options, current, onSelect }) => (
<div className="py-1">
{options.map((opt) => (
<button
key={opt} type="button"
onClick={() => { onSelect(opt); setTab(null); }}
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-white/10 flex items-center justify-between ${current === opt ? "text-white font-medium" : "text-white/60"}`}
>
{opt}
{current === opt && <span className="text-white text-xs">✓</span>}
</button>
))}
</div>
);
return (
<>
<div
className="hidden lg:block absolute bottom-12 right-2 z-20 w-70 rounded-xl overflow-hidden shadow-2xl border border-white/10"
style={{ background: "rgba(18,18,18,0.96)", backdropFilter: "blur(16px)" }}
onClick={(e) => e.stopPropagation()}
>
{tab === null && (
<>
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/30 px-4 pt-3 pb-1">Settings</p>
<Row icon={Gauge} label="Playback speed" value={speed} onClick={() => setTab("speed")} />
<div className="h-2" />
</>
)}
{tab !== null && (
<>
<button type="button" onClick={() => setTab(null)} className="flex items-center gap-1.5 px-4 pt-3 pb-1 text-sm text-white/40 hover:text-white/70 transition-colors w-full">
<ChevronLeft className="size-4" /> Playback speed
</button>
<OptionList options={PLAYBACK_SPEEDS} current={speed} onSelect={onSpeed} />
<div className="h-1" />
</>
)}
</div>
<div
className="lg:hidden absolute bottom-0 left-0 right-0 z-20 rounded-t-2xl border-t border-white/10 overflow-hidden"
style={{ background: "rgba(18,18,18,0.98)", backdropFilter: "blur(20px)" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 pt-2.5 pb-1">
<div className="w-8 h-1 rounded-full bg-white/20 mx-auto" />
<button type="button" onClick={(e) => { e.stopPropagation(); onClose(); }} className="absolute right-3 top-3 text-white/40 hover:text-white transition-colors">
<X className="size-4" />
</button>
</div>
{tab === null && (
<>
<p className="text-[10px] font-semibold uppercase tracking-widest text-white/30 px-4 pt-2 pb-1">Settings</p>
<Row icon={Gauge} label="Playback speed" value={speed} onClick={() => setTab("speed")} />
<div className="h-safe pb-4" />
</>
)}
{tab !== null && (
<>
<button type="button" onClick={() => setTab(null)} className="flex items-center gap-1.5 px-4 pt-3 pb-1 text-sm text-white/40 hover:text-white/70 transition-colors w-full">
<ChevronLeft className="size-4" /> Playback speed
</button>
<div className="flex flex-wrap gap-2 px-4 py-3">
{PLAYBACK_SPEEDS.map((opt) => (
<button
key={opt} type="button"
onClick={() => { onSpeed(opt); setTab(null); }}
className={`px-4 py-1.5 rounded-full text-sm border transition-colors ${speed === opt ? "bg-white text-black border-white font-medium" : "bg-white/10 text-white/70 border-white/10 hover:bg-white/20"}`}
>
{opt}
</button>
))}
</div>
<div className="pb-4" />
</>
)}
</div>
</>
);
}
// ─── VideoBlock (Client — secure) ────────────────────────────────────────────
//
// S3/Garage:
// 1. POST /client/media/token → get JWT token
// 2. fetch(streamUrl, { credentials: "include" }) → get raw bytes
// 3. URL.createObjectURL(blob) → blob:http://... URL
// 4. <video src="blob:..."> → real URL never visible in DOM
//
// Chibisafe:
// → content.url used directly (Chibisafe CDN, no proxy needed)
//
// content shape: { asset_id, url, storage_provider, thumbnail_url? }
export function VideoBlock({ content }) {
const wrapRef = useRef(null);
const vidRef = useRef(null);
// ── Stream state ──────────────────────────────────────────────────────────
const [blobUrl, setBlobUrl] = useState(null);
const [fetchLoading, setFetchLoading] = useState(false);
const [fetchError, setFetchError] = useState(false);
// ── Player state ──────────────────────────────────────────────────────────
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [totalDuration, setTotalDuration] = useState(0);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [ended, setEnded] = useState(false);
const [overlayVisible, setOverlayVisible] = useState(true);
const [controlsVisible, setControlsVisible] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [volumePanelOpen, setVolumePanelOpen] = useState(false);
const [keyFeedback, setKeyFeedback] = useState(null);
const [speed, setSpeed] = useState("Normal");
const [buffered, setBuffered] = useState(0);
const [buffering, setBuffering] = useState(false);
const [hoverProgress, setHoverProgress] = useState(null);
const hideTimer = useRef(null);
const keyFeedbackTimer = useRef(null);
const previewVidRef = useRef(null);
const assetId = content?.asset_id;
const storageProvider = content?.storage_provider;
const poster = content?.thumbnail_url ?? undefined;
// ── Resolve stream URL → set as video src directly ───────────────────────
//
// Previously we fetched all bytes into a Blob and used URL.createObjectURL().
// That blob URL could be opened in a new tab and saved with "Save Video As...".
// Now we set the stream URL directly as <video src> — no blob is ever created.
// The backend blocks direct browser navigation (Sec-Fetch-Mode: navigate → 401)
// and the token is IP-bound, so sharing the URL is ineffective.
useEffect(() => {
if (!assetId) return;
setBlobUrl(null);
setFetchError(false);
setPlaying(false);
setProgress(0);
setCurrentTime(0);
setTotalDuration(0);
setOverlayVisible(true);
setSettingsOpen(false);
setEnded(false);
let cancelled = false;
const load = async () => {
setFetchLoading(true);
try {
// Chibisafe — use raw URL directly
if (storageProvider === "chibisafe") {
const raw = content?.url;
if (!raw) throw new Error("No URL in content");
if (!cancelled) setBlobUrl(raw);
return;
}
// S3 — get token then stream directly; no blob download
const { data } = await api.post("/client/media/token", { asset_id: assetId });
if (cancelled) return;
const { token } = data?.data ?? {};
if (!token) throw new Error("No token returned");
if (!cancelled) setBlobUrl(`${API_BASE}/client/media/stream/${token}`);
} catch (err) {
if (cancelled) return;
console.error("[VideoBlock] load failed", err);
setFetchError(true);
} finally {
if (!cancelled) setFetchLoading(false);
}
};
load();
return () => { cancelled = true; };
}, [assetId, storageProvider]);
// ── Video events ──────────────────────────────────────────────────────────
useEffect(() => {
const v = vidRef.current;
if (!v || !blobUrl) return;
const onTimeUpdate = () => {
setCurrentTime(v.currentTime);
if (v.duration) setProgress((v.currentTime / v.duration) * 100);
};
const onLoaded = () => setTotalDuration(v.duration);
const onEnded = () => { setPlaying(false); setOverlayVisible(false); setEnded(true); };
const onWaiting = () => setBuffering(true);
const onCanPlay = () => setBuffering(false);
const onProgress = () => {
if (v.buffered.length && v.duration) {
setBuffered((v.buffered.end(v.buffered.length - 1) / v.duration) * 100);
}
};
v.addEventListener("waiting", onWaiting);
v.addEventListener("canplay", onCanPlay);
v.addEventListener("timeupdate", onTimeUpdate);
v.addEventListener("loadedmetadata", onLoaded);
v.addEventListener("ended", onEnded);
v.addEventListener("progress", onProgress);
if (v.readyState >= 1 && v.duration) setTotalDuration(v.duration);
return () => {
v.removeEventListener("waiting", onWaiting);
v.removeEventListener("canplay", onCanPlay);
v.removeEventListener("timeupdate", onTimeUpdate);
v.removeEventListener("loadedmetadata", onLoaded);
v.removeEventListener("ended", onEnded);
v.removeEventListener("progress", onProgress);
};
}, [blobUrl]);
useEffect(() => {
const v = vidRef.current;
if (!v) return;
v.playbackRate = speed === "Normal" ? 1 : parseFloat(speed);
}, [speed]);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
const resetHideTimer = useCallback(() => {
setControlsVisible(true);
clearTimeout(hideTimer.current);
if (playing) {
hideTimer.current = setTimeout(() => {
setControlsVisible(false);
setSettingsOpen(false);
}, 3000);
}
}, [playing]);
useEffect(() => {
resetHideTimer();
return () => clearTimeout(hideTimer.current);
}, [playing, resetHideTimer]);
const showFeedback = useCallback((icon, label) => {
setKeyFeedback((prev) => ({ icon, label, key: (prev?.key ?? 0) + 1 }));
clearTimeout(keyFeedbackTimer.current);
keyFeedbackTimer.current = setTimeout(() => setKeyFeedback(null), 800);
}, []);
const togglePlay = useCallback(() => {
const v = vidRef.current;
if (!v) return;
if (v.paused) { v.play(); setPlaying(true); setOverlayVisible(false); setEnded(false); }
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
}, []);
const restart = () => {
const v = vidRef.current;
if (!v) return;
v.currentTime = 0;
v.pause();
setPlaying(false);
setOverlayVisible(true);
};
const handleSeek = (e) => {
const v = vidRef.current;
if (!v || !v.duration) return;
v.currentTime = (parseFloat(e.target.value) / 100) * v.duration;
};
const handleVolumeChange = (e) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (vidRef.current) { vidRef.current.volume = val; vidRef.current.muted = val === 0; }
setMuted(val === 0);
};
const toggleMute = () => {
const v = vidRef.current;
if (!v) return;
v.muted = !v.muted;
setMuted(v.muted);
};
const toggleFullscreen = () => {
const el = wrapRef.current;
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen();
else el.requestFullscreen?.();
};
const handleKeyDown = useCallback((e) => {
if (e.target.tagName === "INPUT") return;
switch (e.key) {
case " ": case "k":
e.preventDefault();
togglePlay();
showFeedback(playing ? <Pause className="size-7 text-white" /> : <Play className="size-7 text-white" />, playing ? "Pause" : "Play");
resetHideTimer();
break;
case "ArrowRight":
e.preventDefault();
if (vidRef.current) vidRef.current.currentTime = Math.min(vidRef.current.currentTime + 5, vidRef.current.duration);
showFeedback(<SkipForward className="size-7 text-white" />, "+5s");
resetHideTimer();
break;
case "ArrowLeft":
e.preventDefault();
if (vidRef.current) vidRef.current.currentTime = Math.max(vidRef.current.currentTime - 5, 0);
showFeedback(<SkipBack className="size-7 text-white" />, "-5s");
resetHideTimer();
break;
case "ArrowUp":
e.preventDefault();
if (vidRef.current) {
const nv = Math.min(volume + 0.1, 1);
vidRef.current.volume = nv; setVolume(nv); setMuted(false);
showFeedback(<Volume2 className="size-7 text-white" />, `${Math.round(nv * 100)}%`);
}
break;
case "ArrowDown":
e.preventDefault();
if (vidRef.current) {
const nv = Math.max(volume - 0.1, 0);
vidRef.current.volume = nv; setVolume(nv); setMuted(nv === 0);
showFeedback(<Volume1 className="size-7 text-white" />, `${Math.round(nv * 100)}%`);
}
break;
case "m":
e.preventDefault();
toggleMute();
showFeedback(muted ? <Volume2 className="size-7 text-white" /> : <VolumeX className="size-7 text-white" />, muted ? "Unmuted" : "Muted");
break;
case "f":
e.preventDefault();
toggleFullscreen();
showFeedback(isFullscreen ? <Minimize2 className="size-7 text-white" /> : <Maximize2 className="size-7 text-white" />, isFullscreen ? "Exit fullscreen" : "Fullscreen");
break;
default: break;
}
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback]);
// ── States ────────────────────────────────────────────────────────────────
if (!assetId) {
return (
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
<VideoIcon className="h-4 w-4" /> No video
</div>
);
}
if (fetchLoading) {
return (
<div className="flex items-center justify-center aspect-video rounded-lg bg-black/90">
<div className="w-10 h-10 rounded-full border-4 border-white/20 border-t-white animate-spin" />
</div>
);
}
if (fetchError || !blobUrl) {
return (
<div className="flex flex-col items-center justify-center aspect-video rounded-lg bg-black/80 gap-2">
<VideoIcon className="h-8 w-8 text-white/30" />
<p className="text-sm text-white/50">Video unavailable.</p>
</div>
);
}
return (
<TooltipProvider delayDuration={400}>
<div
ref={wrapRef}
tabIndex={0}
className="relative w-full rounded-lg overflow-hidden bg-black select-none outline-none"
style={{ aspectRatio: isFullscreen ? undefined : "16/9" }}
onMouseMove={resetHideTimer}
onMouseLeave={() => { if (playing) setControlsVisible(false); }}
onClick={() => { togglePlay(); resetHideTimer(); }}
onKeyDown={handleKeyDown}
onContextMenu={(e) => e.preventDefault()}
>
<video
ref={vidRef}
src={blobUrl}
poster={poster}
preload="auto"
playsInline
controlsList="nodownload nofullscreen noremoteplayback"
disablePictureInPicture
onContextMenu={(e) => e.preventDefault()}
className={`w-full h-full ${isFullscreen ? "object-contain" : "object-cover"}`}
/>
{/* Centre overlay */}
<div
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 pointer-events-none ${overlayVisible ? "opacity-100" : "opacity-0"}`}
style={{ background: "rgba(0,0,0,0.3)" }}
>
<div className="w-14 h-14 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center border border-white/25">
{playing ? <Pause className="size-6 text-white" /> : <Play className="size-6 text-white ml-0.5" />}
</div>
</div>
{/* Buffering spinner */}
{buffering && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-12 h-12 rounded-full border-4 border-white/20 border-t-white animate-spin" />
</div>
)}
{/* Keyboard feedback */}
{keyFeedback && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div
key={keyFeedback.key}
className="flex flex-col items-center gap-1 px-5 py-3 rounded-2xl border border-white/10"
style={{ background: "rgba(0,0,0,0.65)", backdropFilter: "blur(12px)", animation: "fadeInOut 0.8s ease forwards" }}
>
<span className="leading-none">{keyFeedback.icon}</span>
<span className="text-white text-sm font-medium tracking-wide">{keyFeedback.label}</span>
</div>
<style>{`@keyframes fadeInOut{0%{opacity:0;transform:scale(.85)}20%{opacity:1;transform:scale(1)}70%{opacity:1;transform:scale(1)}100%{opacity:0;transform:scale(.95)}}`}</style>
</div>
)}
{/* End overlay */}
{ended && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 pointer-events-none" style={{ background: "rgba(0,0,0,0.55)" }}>
<button
type="button" aria-label="Replay"
className="pointer-events-auto w-16 h-16 rounded-full bg-white/20 hover:bg-white/30 backdrop-blur-sm border border-white/25 flex items-center justify-center transition-colors"
onClick={(e) => {
e.stopPropagation();
const v = vidRef.current;
if (!v) return;
v.currentTime = 0; v.play();
setPlaying(true); setEnded(false); setOverlayVisible(false);
}}
>
<RotateCcw className="size-7 text-white" />
</button>
<span className="text-white/70 text-sm">Replay</span>
</div>
)}
{/* Settings panel */}
{settingsOpen && (
<SettingsPanel speed={speed} onSpeed={setSpeed} onClose={() => setSettingsOpen(false)} />
)}
{/* Controls bar */}
<div
className={`absolute bottom-0 left-0 right-0 transition-opacity duration-300 ${controlsVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
style={{ background: "linear-gradient(to top, rgba(0,0,0,0.98) 0%, rgba(0,0,0,0.4) 80%, transparent 100%)" }}
onClick={(e) => e.stopPropagation()}
>
<div className="px-3 pb-4">
<div
className="relative lg:h-2 xs:h-1 group cursor-pointer"
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const pct = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1);
const time = pct * (vidRef.current?.duration ?? 0);
setHoverProgress({ x: e.clientX - rect.left, pct: pct * 100, time });
if (previewVidRef.current && isFinite(time) && time >= 0) {
previewVidRef.current.currentTime = time;
}
}}
onMouseLeave={() => setHoverProgress(null)}
>
<div className="absolute inset-0 bg-white/25 rounded-full" />
<div className="absolute inset-y-0 left-0 bg-white/40 rounded-full transition-[width] duration-300" style={{ width: `${buffered}%` }} />
<div className="absolute inset-y-0 left-0 bg-white rounded-full" style={{ width: `${progress}%` }} />
<div className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white shadow-md -ml-1.5 opacity-0 group-hover:opacity-100 transition-opacity" style={{ left: `${progress}%` }} />
<input type="range" min="0" max="100" step="0.1" value={progress} onChange={handleSeek} aria-label="Seek" className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
{/* Scrubber preview — also uses blob URL */}
{hoverProgress && (
<div
className="hidden lg:flex absolute bottom-5 flex-col items-center pointer-events-none z-30"
style={{ left: `${hoverProgress.x}px`, transform: "translateX(-50%)" }}
>
<div className="rounded-md overflow-hidden border border-white/20 shadow-xl" style={{ width: 160, height: 90 }}>
<video ref={previewVidRef} src={blobUrl} preload="auto" muted playsInline disablePictureInPicture onContextMenu={(e) => e.preventDefault()} className="w-full h-full object-cover" />
</div>
<span className="text-white text-xs mt-1 font-medium tabular-nums drop-shadow">{fmtTime(hoverProgress.time)}</span>
<div className="w-2 h-2 bg-black/60 rotate-45 -mt-1 border-r border-b border-white/20" />
</div>
)}
</div>
</div>
<div className="flex items-center gap-3 px-2.5 pb-3">
<CtrlBtn label={playing ? "Pause" : "Play"} onClick={togglePlay}>
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
</CtrlBtn>
<CtrlBtn label="Restart" onClick={restart}>
<SkipBack className="size-4" />
</CtrlBtn>
<div className="relative" onClick={(e) => e.stopPropagation()}>
{volumePanelOpen && (
<div
className="lg:hidden absolute bottom-9 left-1/2 -translate-x-1/2 z-30 rounded-xl border border-white/10 px-4 py-3 flex flex-col items-center gap-2"
style={{ background: "rgba(18,18,18,0.96)", backdropFilter: "blur(16px)" }}
>
<span className="text-[10px] text-white/40 uppercase tracking-widest">Volume</span>
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolumeChange} aria-label="Volume" className="accent-white cursor-pointer" style={{ writingMode: "vertical-lr", direction: "rtl", height: "80px", width: "auto" }} />
<span className="text-xs text-white/50 tabular-nums">{muted ? "0" : Math.round(volume * 100)}%</span>
</div>
)}
<CtrlBtn
label={muted ? "Unmute" : "Mute"}
onClick={() => {
if (window.innerWidth < 1024) setVolumePanelOpen((o) => !o);
else toggleMute();
}}
>
{muted ? <VolumeX className="size-4 sm:size-5" /> : <Volume2 className="size-4 sm:size-5" />}
</CtrlBtn>
</div>
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolumeChange} aria-label="Volume" className="hidden lg:block w-16 accent-white cursor-pointer" onClick={(e) => e.stopPropagation()} />
<span className="text-white/60 text-sm tabular-nums ml-1.5">
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
</span>
<div className="ml-auto flex items-center gap-3">
{speed !== "Normal" && (
<span className="text-sm text-white/60 bg-white/10 px-1.5 py-0.5 rounded-sm font-mono">{speed}x</span>
)}
<CtrlBtn label="Settings" onClick={(e) => { e.stopPropagation(); setSettingsOpen((o) => !o); }} className={settingsOpen ? "text-white" : ""}>
<Settings className={`size-5 transition-transform duration-300 ${settingsOpen ? "rotate-45" : ""}`} />
</CtrlBtn>
<CtrlBtn label={isFullscreen ? "Exit fullscreen" : "Fullscreen"} onClick={toggleFullscreen}>
{isFullscreen ? <Minimize2 className="size-5" /> : <Maximize2 className="size-5" />}
</CtrlBtn>
</div>
</div>
</div>
</div>
</TooltipProvider>
);
}
@@ -1,64 +0,0 @@
import { useState } from "react";
import { VideoIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../AssetPickerSheet";
export function VideoBlock({ content, onUpdate }) {
const [pickerOpen, setPickerOpen] = useState(false);
const thumb = content.thumbnail_url ?? null;
return (
<div className="space-y-3">
<Label>Video</Label>
{content.url ? (
<div
className="relative rounded-lg overflow-hidden cursor-pointer group"
onClick={() => setPickerOpen(true)}
>
{thumb ? (
<img
src={thumb}
alt="Video thumbnail"
className="w-full aspect-video object-cover"
/>
) : (
<div className="w-full aspect-video bg-muted flex items-center justify-center">
<VideoIcon className="h-10 w-10 text-muted-foreground/50" />
</div>
)}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<p className="text-white text-sm font-medium">Change Video</p>
</div>
{/* Play icon overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="h-12 w-12 rounded-full bg-black/50 flex items-center justify-center">
<VideoIcon className="h-5 w-5 text-white" />
</div>
</div>
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full aspect-video rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
>
<VideoIcon className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Click to select a video</p>
</button>
)}
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="video"
onSelect={(asset) => onUpdate({
...content,
asset_id: asset.asset_id,
url: asset.file_url,
thumbnail_url: asset.thumbnail_url ?? null,
})}
/>
</div>
);
}
@@ -66,14 +66,14 @@ const AppBreadcrumb = ({ items = [] }) => {
<span key={index} className="flex items-center gap-1.5">
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage className="flex items-center gap-2 max-w-[200px] truncate">
<BreadcrumbPage className="flex items-center gap-2 max-w-[300px] truncate">
{item.icon}
<span className="truncate">{item.label}</span>
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<div
className="flex items-center gap-2 select-none cursor-pointer max-w-[200px]"
className="flex items-center gap-2 select-none cursor-pointer max-w-[300px]"
onClick={(e) => {
if (item.onClick) {
item.onClick(e, navigate);
@@ -0,0 +1,195 @@
import { useState } from "react";
import { Bell, Trophy, BookOpen, Star, CheckCircle, Copy, Check, Megaphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useClientNotifications } from "@/contexts/ClientNotificationContext";
import { cn } from "@/lib/utils";
const TYPE_ICON = {
achievement: Trophy,
course: BookOpen,
milestone: Star,
task: CheckCircle,
announcement: Megaphone,
};
function NotificationIcon({ type, className }) {
const Icon = TYPE_ICON[type] ?? Bell;
return <Icon className={cn("shrink-0", className)} />;
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60_000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
export default function ClientNotificationBell() {
const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } =
useClientNotifications();
const [selected, setSelected] = useState(null);
const [copiedCode, setCopiedCode] = useState(false);
async function handleCopyCode(code) {
await navigator.clipboard.writeText(code);
setCopiedCode(true);
setTimeout(() => setCopiedCode(false), 2000);
}
function handleOpen(open) {
if (open) fetchNotifications();
}
function handleClickNotification(n) {
if (!n.seen) markSeen(n.notification_id);
setSelected(n);
}
return (
<>
<Popover onOpenChange={handleOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="relative">
<Bell className="h-4 w-4" />
{unseenCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white leading-none">
{unseenCount > 99 ? "99+" : unseenCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-semibold">Notifications</span>
{unseenCount > 0 && (
<button
onClick={markAllSeen}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Mark all as read
</button>
)}
</div>
<Separator />
<ScrollArea className="h-80">
{loading && notifications.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground">Loading...</p>
) : notifications.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground">No notifications yet.</p>
) : (
<ul>
{notifications.map((n, i) => (
<li key={n.notification_id}>
<button
onClick={() => handleClickNotification(n)}
className={cn(
"w-full text-left px-4 py-3 hover:bg-muted/50 transition-colors",
!n.seen && "bg-blue-50 dark:bg-blue-950/20"
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<NotificationIcon type={n.type} className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{!n.seen && (
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-500" />
)}
<p className="text-xs font-medium truncate">{n.title}</p>
</div>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{n.message}</p>
<p className="text-[10px] text-muted-foreground mt-1">{timeAgo(n.createdAt)}</p>
</div>
</div>
</button>
{i < notifications.length - 1 && <Separator />}
</li>
))}
</ul>
)}
</ScrollArea>
</PopoverContent>
</Popover>
{/* Detail dialog — rendered outside the Popover so it isn't clipped */}
<Dialog open={!!selected} onOpenChange={(open) => { if (!open) { setSelected(null); setCopiedCode(false); } }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted">
<NotificationIcon
type={selected?.type}
className="h-5 w-5 text-foreground"
/>
</div>
<DialogTitle className="leading-snug">{selected?.title}</DialogTitle>
</div>
<DialogDescription className="text-sm text-foreground/80 leading-relaxed">
{selected?.message}
</DialogDescription>
</DialogHeader>
<Separator />
{selected?.data?.groupCode && (
<>
<div className="flex flex-col gap-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Group Code
</p>
<div className="flex items-center gap-2">
<span className="flex-1 font-mono text-sm bg-muted rounded-lg px-3 py-2 truncate">
{selected.data.groupCode}
</span>
<Button
size="sm"
variant="outline"
className="shrink-0 gap-1.5"
onClick={() => handleCopyCode(selected.data.groupCode)}
>
{copiedCode
? <><Check className="size-3.5" /> Copied</>
: <><Copy className="size-3.5" /> Copy</>
}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Share this code with others so they can join your group.
</p>
</div>
<Separator />
</>
)}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="capitalize">{selected?.type}</span>
<span>{selected ? formatDate(selected.createdAt) : ""}</span>
</div>
</DialogContent>
</Dialog>
</>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useState } from "react";
import { Bell, AlertCircle, UserPlus, Megaphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { useAdminNotifications } from "@/contexts/AdminNotificationContext";
import { cn } from "@/lib/utils";
const TYPE_ICON = {
task_overdue: AlertCircle,
user_registration: UserPlus,
announcement: Megaphone,
};
function NotificationIcon({ type, className }) {
const Icon = TYPE_ICON[type] ?? Bell;
return <Icon className={cn("shrink-0", className)} />;
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const m = Math.floor(diff / 60_000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
}
export default function NotificationBell() {
const { notifications, unseenCount, loading, fetchNotifications, markSeen, markAllSeen } =
useAdminNotifications();
const [selected, setSelected] = useState(null);
function handleOpen(open) {
if (open) fetchNotifications();
}
function handleClickNotification(n) {
if (!n.seen) markSeen(n.notification_id);
setSelected(n);
}
return (
<>
<Popover onOpenChange={handleOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unseenCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white leading-none">
{unseenCount > 99 ? "99+" : unseenCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-semibold">Notifications</span>
{unseenCount > 0 && (
<button
onClick={markAllSeen}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Mark all as read
</button>
)}
</div>
<Separator />
<ScrollArea className="h-80">
{loading && notifications.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground">Loading...</p>
) : notifications.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground">No notifications yet.</p>
) : (
<ul>
{notifications.map((n, i) => (
<li key={n.notification_id}>
<button
onClick={() => handleClickNotification(n)}
className={cn(
"w-full text-left px-4 py-3 hover:bg-muted/50 transition-colors",
!n.seen && "bg-blue-50 dark:bg-blue-950/20"
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<NotificationIcon type={n.type} className="h-4 w-4 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
{!n.seen && (
<span className="h-2 w-2 shrink-0 rounded-full bg-blue-500" />
)}
<p className="text-xs font-medium truncate">{n.title}</p>
</div>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{n.message}</p>
<p className="text-[10px] text-muted-foreground mt-1">{timeAgo(n.createdAt)}</p>
</div>
</div>
</button>
{i < notifications.length - 1 && <Separator />}
</li>
))}
</ul>
)}
</ScrollArea>
</PopoverContent>
</Popover>
{/* Detail dialog — outside Popover so it isn't clipped */}
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex items-center gap-3 mb-1">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted">
<NotificationIcon
type={selected?.type}
className="h-5 w-5 text-foreground"
/>
</div>
<DialogTitle className="leading-snug">{selected?.title}</DialogTitle>
</div>
<DialogDescription className="text-sm text-foreground/80 leading-relaxed">
{selected?.message}
</DialogDescription>
</DialogHeader>
<Separator />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span className="capitalize">{selected?.type?.replace(/_/g, ' ')}</span>
<span>{selected ? formatDate(selected.createdAt) : ""}</span>
</div>
</DialogContent>
</Dialog>
</>
);
}
+71 -45
View File
@@ -1,6 +1,8 @@
import { useAuth } from '@/contexts/AuthContext'
import { useState } from 'react'
import { Camera, Phone, MapPin, Shield, Trophy, Activity, Plus, Trash2, Pencil, Home, Building2, Edit2, Check, X } from 'lucide-react'
import { useProfile } from '@/contexts/ProfileProvider'
import { useState, useEffect } from 'react'
import { Camera, Loader2, Phone, MapPin, Shield, Trophy, Activity, Plus, Trash2, Pencil, Home, Building2, Edit2, Check, X } from 'lucide-react'
import AvatarUploadDialog from '@/components/generic/AvatarUploadDialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -10,14 +12,14 @@ import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'
import { useForm, Controller } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { MOCK_ACTIVITIES, MOCK_ACHIEVEMENTS, ROLE_CONFIG, ACTIVITY_VARIANTS, AVATAR_COLORS } from '@/data/profile.data'
import { ROLE_CONFIG, AVATAR_COLORS } from '@/data/profile.data'
// ─── Schemas ──────────────────────────────────────────────────────────────────
const addressSchema = z.object({
@@ -52,7 +54,7 @@ function AddressDialog({ open, onClose, initial, onSave }) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{initial ? 'Edit address' : 'Add address'}</DialogTitle>
</DialogHeader>
@@ -106,8 +108,10 @@ function AddressDialog({ open, onClose, initial, onSave }) {
</div>
</div>
<DialogFooter className="pt-2">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">{initial ? 'Save changes' : 'Add address'}</Button>
</DialogFooter>
</form>
@@ -130,7 +134,7 @@ function PhoneDialog({ open, onClose, initial, onSave }) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-sm">
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{initial ? 'Edit phone number' : 'Add phone number'}</DialogTitle>
</DialogHeader>
@@ -165,8 +169,10 @@ function PhoneDialog({ open, onClose, initial, onSave }) {
</div>
</div>
<DialogFooter className="pt-2">
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">{initial ? 'Save changes' : 'Add number'}</Button>
</DialogFooter>
</form>
@@ -203,9 +209,18 @@ function DeleteConfirm({ open, onClose, onConfirm, label }) {
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ProfilePage() {
const { user } = useAuth()
const {
profile, getProfile,
updateProfile, profileLoading,
uploadAvatar, deleteAvatar, avatarLoading,
} = useProfile()
const isClient = user?.acc_type === 'client'
const role = ROLE_CONFIG[user?.acc_type] ?? { label: user?.acc_type, variant: 'outline' }
const info = user?.personal_info
// Use fresh profile data; fall back to auth context while loading
const info = profile?.personal_info ?? user?.personal_info
const [avatarDialogOpen, setAvatarDialogOpen] = useState(false)
const [editing, setEditing] = useState(false)
const [addresses, setAddresses] = useState(info?.addresses ?? [])
@@ -218,9 +233,25 @@ export default function ProfilePage() {
const fullName = info?.name?.full_name ?? user?.email ?? 'User'
const initials = ((info?.name?.given_name?.[0] ?? '') + (info?.name?.last_name?.[0] ?? '')).toUpperCase() || user?.email?.[0]?.toUpperCase() || 'U'
const avatarUrl = info?.avatar ?? null
const avatarUrl = info?.avatar?.url ?? null
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
// Load fresh profile data on mount
useEffect(() => { getProfile(); }, [])
// Sync phones/addresses when profile arrives from API
useEffect(() => {
if (!profile?.personal_info) return
setAddresses(profile.personal_info.addresses ?? [])
setPhones(profile.personal_info.phone_number ?? [])
}, [profile])
// Save profile changes (phones + addresses)
const handleSave = async () => {
const result = await updateProfile({ phone_number: phones, addresses })
if (result?.success) setEditing(false)
}
// ── Address handlers ──
const handleSaveAddress = (data) => {
if (addrDialog.index !== null) {
@@ -265,7 +296,10 @@ export default function ProfilePage() {
</Avatar>
<Tooltip>
<TooltipTrigger asChild>
<button className="absolute -bottom-1 -right-1 p-1.5 rounded-full bg-primary text-primary-foreground shadow hover:opacity-80 transition-opacity">
<button
className="absolute -bottom-1 -right-1 p-1.5 rounded-full bg-primary text-primary-foreground shadow hover:opacity-80 transition-opacity"
onClick={() => setAvatarDialogOpen(true)}
>
<Camera size={11} />
</button>
</TooltipTrigger>
@@ -278,9 +312,11 @@ export default function ProfilePage() {
<h1 className="text-xl font-semibold">{fullName}</h1>
<p className="text-sm text-muted-foreground">{user?.email}</p>
<div className="mt-2 flex items-center gap-2 flex-wrap">
<Badge className="bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300">
{info.occupation}
</Badge>
{info?.occupation && (
<Badge className="bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300">
{info.occupation}
</Badge>
)}
<Badge variant={role.variant}>{role.label}</Badge>
</div>
</div>
@@ -289,8 +325,12 @@ export default function ProfilePage() {
<div className="flex gap-2 shrink-0">
{editing ? (
<>
<Button size="sm" onClick={() => setEditing(false)} className="gap-1.5">
<Check size={13} /> Save profile
<Button size="sm" onClick={handleSave} disabled={profileLoading} className="gap-1.5">
{profileLoading
? <Loader2 size={13} className="animate-spin" />
: <Check size={13} />
}
Save profile
</Button>
<Button size="sm" variant="outline" onClick={() => setEditing(false)} className="gap-1.5">
<X size={13} /> Cancel
@@ -421,19 +461,7 @@ export default function ProfilePage() {
</CardHeader>
<CardContent>
<Separator className="mb-1" />
<div className="divide-y">
{MOCK_ACTIVITIES.map((item) => (
<div key={item.id} className="flex items-start gap-3 py-3">
<Badge variant={ACTIVITY_VARIANTS[item.type]} className="mt-0.5 capitalize shrink-0">
{item.type}
</Badge>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium leading-snug">{item.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">{item.date}</p>
</div>
</div>
))}
</div>
<p className="text-xs text-muted-foreground py-2">No recent activities.</p>
</CardContent>
</Card>
@@ -442,23 +470,11 @@ export default function ProfilePage() {
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Trophy size={14} className="text-muted-foreground" />
Achievements
<Badge variant="secondary" className="ml-auto">{MOCK_ACHIEVEMENTS.length} earned</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<Separator className="mb-3" />
<div className="space-y-2">
{MOCK_ACHIEVEMENTS.map((item) => (
<div key={item.id} className="flex items-center gap-3 p-3 rounded-lg border bg-card hover:bg-accent/50 transition-colors">
<span className="text-2xl">{item.icon}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold">{item.title}</p>
<p className="text-xs text-muted-foreground">{item.desc}</p>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">{item.date}</span>
</div>
))}
</div>
<p className="text-xs text-muted-foreground py-2">No achievements yet.</p>
</CardContent>
</Card>
</>
@@ -505,6 +521,16 @@ export default function ProfilePage() {
onConfirm={handleDeletePhone}
label="phone number"
/>
<AvatarUploadDialog
open={avatarDialogOpen}
onClose={() => setAvatarDialogOpen(false)}
currentAvatarUrl={avatarUrl ?? ''}
initials={initials}
onUpload={uploadAvatar}
onDelete={deleteAvatar}
loading={avatarLoading}
/>
</TooltipProvider>
)
}
+238
View File
@@ -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>
//
// ===========================================================================
+28
View File
@@ -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;
+5 -111
View File
@@ -2,33 +2,19 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/contexts/AuthContext'
import { useTheme } from '@/contexts/ThemeContext'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useProfile } from '@/contexts/ProfileProvider'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { AlertDialog, AlertDialogContent, } from '@/components/ui/alert-dialog'
import { User, Settings, LogOut, X, Sun, Moon, Monitor, Lock, Loader2, Check } from 'lucide-react'
import { User, Settings, LogOut, Sun, Moon, Monitor, Loader2, Check } from 'lucide-react'
import { AVATAR_COLORS } from '@/data/profile.data'
// ─── Password schema ──────────────────────────────────────────────────────────
const passwordSchema = z.object({
current_password: z.string().min(1, 'Current password is required'),
new_password: z.string().min(8, 'At least 8 characters'),
confirm_password: z.string(),
}).refine((d) => d.new_password === d.confirm_password, {
message: 'Passwords do not match',
path: ['confirm_password'],
})
// ─── Theme Option ─────────────────────────────────────────────────────────────
function ThemeOption({ value, label, icon: Icon, active, onClick }) {
return (
@@ -57,41 +43,20 @@ function ThemeOption({ value, label, icon: Icon, active, onClick }) {
function SettingsDialog({ open, onClose }) {
const { theme, setTheme } = useTheme()
const [tab, setTab] = useState('appearance')
const [pwSuccess, setPwSuccess] = useState(false)
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm({ resolver: zodResolver(passwordSchema) })
const onPasswordSubmit = async (data) => {
// TODO: call your API here
// await api.post('/auth/change-password', data)
await new Promise((r) => setTimeout(r, 800)) // mock delay
setPwSuccess(true)
reset()
setTimeout(() => setPwSuccess(false), 3000)
}
const TABS = [
{ id: 'appearance', label: 'Appearance', icon: Sun },
{ id: 'password', label: 'Password', icon: Lock },
]
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="p-0 overflow-hidden max-w-2xl gap-0 rounded-2xl">
<DialogContent className="p-0 overflow-hidden sm:max-w-2xl gap-0 rounded-2xl">
<div className="flex h-[480px]">
{/* ── Sidebar ── */}
<div className="w-52 shrink-0 border-r bg-muted/30 flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b">
<div className="flex items-center px-4 py-3 border-b">
<span className="text-sm font-semibold">Settings</span>
<Button variant="ghost" size="icon" className="size-7 rounded-md" onClick={onClose}>
<X size={14} />
</Button>
</div>
<nav className="flex-1 p-2 space-y-0.5">
{TABS.map((t) => (
@@ -135,77 +100,6 @@ function SettingsDialog({ open, onClose }) {
</div>
)}
{/* Password */}
{tab === 'password' && (
<div className="space-y-5">
<div>
<h2 className="text-base font-semibold">Change password</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Update your password to keep your account secure.
</p>
</div>
<Separator />
{pwSuccess && (
<div className="flex items-center gap-2 text-sm text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400 px-3 py-2 rounded-md border border-green-200 dark:border-green-800">
<Check size={14} /> Password updated successfully.
</div>
)}
<form onSubmit={handleSubmit(onPasswordSubmit)} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="current_password">Current password</Label>
<Input
id="current_password"
type="password"
autoComplete="current-password"
disabled={isSubmitting}
{...register('current_password')}
/>
{errors.current_password && (
<p className="text-xs text-destructive">{errors.current_password.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="new_password">New password</Label>
<Input
id="new_password"
type="password"
autoComplete="new-password"
disabled={isSubmitting}
{...register('new_password')}
/>
{errors.new_password && (
<p className="text-xs text-destructive">{errors.new_password.message}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm_password">Confirm new password</Label>
<Input
id="confirm_password"
type="password"
autoComplete="new-password"
disabled={isSubmitting}
{...register('confirm_password')}
/>
{errors.confirm_password && (
<p className="text-xs text-destructive">{errors.confirm_password.message}</p>
)}
</div>
<Button type="submit" size="sm" disabled={isSubmitting} className="gap-1.5">
{isSubmitting ? (
<><Loader2 size={13} className="animate-spin" /> Updating...</>
) : (
'Update password'
)}
</Button>
</form>
</div>
)}
</div>
</div>
</DialogContent>
@@ -230,6 +124,7 @@ function SignOutOverlay({ open }) {
// ─── Main UserMenu ────────────────────────────────────────────────────────────
export default function UserMenu() {
const { user, logout } = useAuth()
const { avatarUrl } = useProfile()
const navigate = useNavigate()
const [settingsOpen, setSettingsOpen] = useState(false)
@@ -245,7 +140,6 @@ export default function UserMenu() {
const fullName = given && last ? `${given} ${last}` : user?.email ?? 'User'
const shortName = (given || user?.email) ?? 'User'
const avatarUrl = user?.personal_info?.avatar ?? null
const avatarColor = AVATAR_COLORS[user?.acc_type] ?? 'bg-muted text-muted-foreground'
const handleLogout = async () => {