mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : FilePreview.jsx
|
||||
* Type : Component (Client)
|
||||
* Description : Dialog-based file preview for task completion attachments.
|
||||
* Renders by mime type:
|
||||
* image/jpeg, image/png → FileZoomViewer (zoom/pan/fit)
|
||||
* application/pdf → FileZoomViewer (pdf.js zoom/pan/fit)
|
||||
* video/* → <video controls> (mp4, mov, webm, etc.)
|
||||
* audio/* → centered audio player card (mp3, wav, etc.)
|
||||
* other (incl. docx) → generic file card with download prompt
|
||||
*
|
||||
* Inline preview uses the blob-fetch pattern (same as
|
||||
* VideoBlock/AudioBlock): authenticated GET via `api` →
|
||||
* responseType 'blob' → URL.createObjectURL → set as src.
|
||||
* This is required because <img>/<video>/<audio>/<iframe> src
|
||||
* are native browser requests that don't carry the Authorization
|
||||
* header attached by the axios interceptor.
|
||||
*
|
||||
* Props:
|
||||
* file {object} – { file_id, file_url, file_name, file_size, mime_type, createdAt }
|
||||
* open {boolean}
|
||||
* onOpenChange {function}
|
||||
* streamUrl {string} – proxy stream URL (inline preview, authenticated)
|
||||
* downloadUrl {string} – proxy download URL (Content-Disposition: attachment)
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Image, Video, Music, FileText, File, Download,
|
||||
} from 'lucide-react';
|
||||
import { formatDate } from '@/utils/table.util';
|
||||
import { formatBytes } from './blocks/FileUpload';
|
||||
import api from '@/utils/api.util';
|
||||
import FileZoomViewer from './FileZoomViewer';
|
||||
|
||||
// ─── Resolve preview kind from mime type ──────────────────────────────────────
|
||||
const resolveKind = (mimeType = '', fileName = '') => {
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
if (mimeType.startsWith('video/')) return 'video';
|
||||
if (mimeType.startsWith('audio/')) return 'audio';
|
||||
if (mimeType === 'application/pdf') return 'pdf';
|
||||
|
||||
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
||||
if (['mp4', 'mov', 'webm', 'avi'].includes(ext)) return 'video';
|
||||
if (['mp3', 'wav', 'm4a', 'ogg'].includes(ext)) return 'audio';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
return 'other';
|
||||
};
|
||||
|
||||
const KIND_ICON = {
|
||||
image: Image,
|
||||
video: Video,
|
||||
audio: Music,
|
||||
pdf: FileText,
|
||||
other: File,
|
||||
};
|
||||
|
||||
// ─── useBlobUrl — fetches streamUrl via authenticated api, returns blob URL ────
|
||||
const useBlobUrl = (streamUrl, enabled) => {
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !streamUrl) return;
|
||||
|
||||
let currentUrl = null;
|
||||
let cancelled = false;
|
||||
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setBlobUrl(null);
|
||||
|
||||
api.get(streamUrl, { responseType: 'blob' })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
currentUrl = URL.createObjectURL(res.data);
|
||||
setBlobUrl(currentUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (currentUrl) URL.revokeObjectURL(currentUrl);
|
||||
};
|
||||
}, [streamUrl, enabled]);
|
||||
|
||||
return { blobUrl, loading, error };
|
||||
};
|
||||
|
||||
// ─── Loading / error placeholder ───────────────────────────────────────────────
|
||||
const LoadingState = () => (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const ErrorState = ({ onDownload, downloading, fileName }) => (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-muted-foreground">
|
||||
<File className="size-12" />
|
||||
<p className="text-sm">Could not load preview.</p>
|
||||
{onDownload && (
|
||||
<Button variant="outline" size="sm" onClick={onDownload} disabled={downloading}>
|
||||
<Download className="size-4" />
|
||||
{downloading ? 'Downloading…' : 'Download file'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Preview body per kind ─────────────────────────────────────────────────────
|
||||
const PreviewBody = ({ file, kind, blobUrl, loading, error, downloadUrl, onDownload, downloading }) => {
|
||||
if (kind === 'other') {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-muted-foreground">
|
||||
<File className="size-12" />
|
||||
<p className="text-sm">Preview not available for this file type.</p>
|
||||
<Button variant="outline" size="sm" onClick={onDownload} disabled={downloading}>
|
||||
<Download className="size-4" />
|
||||
{downloading ? 'Downloading…' : 'Download file'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Image / PDF — zoom/pan/fit viewer ───────────────────────────────────────
|
||||
if (kind === 'image' || kind === 'pdf') {
|
||||
if (error) return <ErrorState onDownload={onDownload} downloading={downloading} fileName={file.file_name} />;
|
||||
return (
|
||||
<FileZoomViewer
|
||||
blobUrl={blobUrl}
|
||||
mimeType={file.mime_type}
|
||||
fileName={file.file_name}
|
||||
loading={loading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <LoadingState />;
|
||||
if (error || !blobUrl) return <ErrorState onDownload={onDownload} downloading={downloading} fileName={file.file_name} />;
|
||||
|
||||
switch (kind) {
|
||||
case 'video':
|
||||
return (
|
||||
<div className="bg-black flex items-center justify-center aspect-video">
|
||||
<video
|
||||
src={blobUrl}
|
||||
controls
|
||||
controlsList="nodownload nofullscreen noremoteplayback"
|
||||
disablePictureInPicture
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'audio':
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-8 px-6">
|
||||
<div className="size-24 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Music className="size-9 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium truncate max-w-xs">{file.file_name}</p>
|
||||
<p className="text-xs text-muted-foreground">Audio file</p>
|
||||
</div>
|
||||
<audio
|
||||
src={blobUrl}
|
||||
controls
|
||||
controlsList="nodownload"
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="w-full max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Trigger a browser download from a blob ────────────────────────────────────
|
||||
const downloadBlob = (blob, fileName) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// ─── Main dialog ────────────────────────────────────────────────────────────────
|
||||
const FilePreview = ({ file, open, onOpenChange, streamUrl, downloadUrl }) => {
|
||||
const kind = file ? resolveKind(file.mime_type, file.file_name) : 'other';
|
||||
const Icon = KIND_ICON[kind] ?? File;
|
||||
|
||||
// Only fetch the blob when the dialog is open and a previewable kind
|
||||
const shouldFetch = open && !!file && kind !== 'other';
|
||||
const { blobUrl, loading, error } = useBlobUrl(streamUrl, shouldFetch);
|
||||
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
// ── Download handler — authenticated fetch via `api`, then save blob ──────
|
||||
const handleDownload = async () => {
|
||||
const url = downloadUrl || streamUrl;
|
||||
if (!url) return;
|
||||
|
||||
setDownloading(true);
|
||||
try {
|
||||
const res = await api.get(url, { responseType: 'blob' });
|
||||
downloadBlob(res.data, file.file_name);
|
||||
} catch {
|
||||
// fall back to direct link if proxy fails (e.g. public file_url)
|
||||
if (file.file_url) window.open(file.file_url, '_blank');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg lg:max-w-4xl p-0 gap-0 overflow-hidden">
|
||||
<DialogHeader className="px-4 py-3 pr-12 border-b flex-row items-center justify-between gap-3 space-y-0">
|
||||
<div className="flex items-center gap-2 min-w-0 select-none">
|
||||
<Icon className="size-4.5 text-muted-foreground shrink-0" />
|
||||
<DialogTitle className="text-sm font-medium truncate max-w-54">
|
||||
{file.file_name}
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={downloading}
|
||||
aria-label="Download"
|
||||
>
|
||||
{downloading ? <Spinner className="size-4" /> : <Download className="size-4" />} Download
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<PreviewBody
|
||||
file={file}
|
||||
kind={kind}
|
||||
blobUrl={blobUrl}
|
||||
loading={loading}
|
||||
error={error}
|
||||
downloadUrl={downloadUrl}
|
||||
onDownload={handleDownload}
|
||||
downloading={downloading}
|
||||
/>
|
||||
|
||||
<div className="px-4 py-2.5 border-t flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
{file.mime_type && (
|
||||
<Badge variant="secondary" className="text-xs font-normal select-none">
|
||||
{file.mime_type}
|
||||
</Badge>
|
||||
)}
|
||||
{file.file_size != null && (
|
||||
<Badge variant="secondary" className="text-xs font-normal select-none">
|
||||
{formatBytes(file.file_size)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{file.createdAt && (
|
||||
<span>Uploaded {formatDate(file.createdAt)}</span>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilePreview;
|
||||
@@ -0,0 +1,298 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : FileZoomViewer.jsx
|
||||
* Type : Component (Client)
|
||||
* Description : Zoom/pan/fit viewer for image and PDF files.
|
||||
*
|
||||
* Supported:
|
||||
* image/jpeg, image/png → <img> with scroll-zoom + drag-pan
|
||||
* application/pdf → pdf.js renders the current page to
|
||||
* <canvas>, same zoom/pan controls,
|
||||
* with page navigation for multi-page PDFs
|
||||
*
|
||||
* Not supported (shows a message instead of attempting render):
|
||||
* DOCX, video, audio, and any other file type
|
||||
*
|
||||
* Props:
|
||||
* blobUrl {string} – object URL (from FilePreview's useBlobUrl)
|
||||
* mimeType {string}
|
||||
* fileName {string}
|
||||
* loading {boolean} – true while the parent is still fetching the blob
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
ZoomIn, ZoomOut, Maximize2, ChevronLeft, ChevronRight, FileWarning,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const MIN_SCALE = 0.25;
|
||||
const MAX_SCALE = 4;
|
||||
const SCALE_STEP = 0.25;
|
||||
|
||||
// ─── Resolve viewer mode from mime type / extension ───────────────────────────
|
||||
const resolveMode = (mimeType = '', fileName = '') => {
|
||||
if (mimeType === 'image/jpeg' || mimeType === 'image/png') return 'image';
|
||||
if (mimeType === 'application/pdf') return 'pdf';
|
||||
|
||||
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
|
||||
if (['jpg', 'jpeg', 'png'].includes(ext)) return 'image';
|
||||
if (ext === 'pdf') return 'pdf';
|
||||
return 'unsupported';
|
||||
};
|
||||
|
||||
// ─── Not supported message ─────────────────────────────────────────────────────
|
||||
const UnsupportedMessage = ({ fileName }) => (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-muted-foreground">
|
||||
<FileWarning className="size-12" />
|
||||
<p className="text-sm font-medium">Full preview not supported for this file type.</p>
|
||||
<p className="text-xs max-w-xs text-center">
|
||||
{fileName ? `"${fileName}" ` : 'This file '}
|
||||
can't be opened in the zoom viewer. JPEG, PNG, and PDF files are supported.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Toolbar ──────────────────────────────────────────────────────────────────
|
||||
const ZoomToolbar = ({
|
||||
scale, onZoomIn, onZoomOut, onFit,
|
||||
page, numPages, onPrevPage, onNextPage,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b bg-muted/40">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={onZoomOut} disabled={scale <= MIN_SCALE} aria-label="Zoom out">
|
||||
<ZoomOut className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs w-12 text-center tabular-nums select-none">{Math.round(scale * 100)}%</span>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={onZoomIn} disabled={scale >= MAX_SCALE} aria-label="Zoom in">
|
||||
<ZoomIn className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onFit} aria-label="Fit to screen">
|
||||
<Maximize2 className="size-4" /> Fit to screen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{numPages > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={onPrevPage} disabled={page <= 1} aria-label="Previous page">
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs tabular-nums">{page} / {numPages}</span>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={onNextPage} disabled={page >= numPages} aria-label="Next page">
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ─── Shared zoom/pan canvas wrapper ─────────────────────────────────────────────
|
||||
// Wraps any child (img or canvas) with scroll-to-zoom + drag-to-pan behavior.
|
||||
const ZoomPanArea = ({ scale, setScale, offset, setOffset, fitFn, children }) => {
|
||||
const containerRef = useRef(null);
|
||||
const dragRef = useRef({ dragging: false, startX: 0, startY: 0, origX: 0, origY: 0 });
|
||||
|
||||
// ── Scroll to zoom (centered on cursor) ────────────────────────────────────
|
||||
const onWheel = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -SCALE_STEP : SCALE_STEP;
|
||||
setScale((s) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, +(s + delta).toFixed(2))));
|
||||
}, [setScale]);
|
||||
|
||||
// ── Drag to pan ───────────────────────────────────────────────────────────
|
||||
const onPointerDown = (e) => {
|
||||
dragRef.current = {
|
||||
dragging: true,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origX: offset.x,
|
||||
origY: offset.y,
|
||||
};
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
if (!dragRef.current.dragging) return;
|
||||
const dx = e.clientX - dragRef.current.startX;
|
||||
const dy = e.clientY - dragRef.current.startY;
|
||||
setOffset({ x: dragRef.current.origX + dx, y: dragRef.current.origY + dy });
|
||||
};
|
||||
const onPointerUp = (e) => {
|
||||
dragRef.current.dragging = false;
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => el.removeEventListener('wheel', onWheel);
|
||||
}, [onWheel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative overflow-hidden bg-muted h-[420px] flex items-center justify-center',
|
||||
scale > 1 ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
|
||||
)}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onDoubleClick={fitFn}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
||||
transformOrigin: 'center center',
|
||||
transition: dragRef.current.dragging ? 'none' : 'transform 0.1s ease-out',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Image viewer ───────────────────────────────────────────────────────────────
|
||||
const ImageViewer = ({ blobUrl, fileName }) => {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
|
||||
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<ZoomToolbar
|
||||
scale={scale}
|
||||
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
|
||||
onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, +(s - SCALE_STEP).toFixed(2)))}
|
||||
onFit={fit}
|
||||
page={1}
|
||||
numPages={1}
|
||||
onPrevPage={() => {}}
|
||||
onNextPage={() => {}}
|
||||
/>
|
||||
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={fileName}
|
||||
draggable={false}
|
||||
className="max-h-[380px] max-w-none select-none pointer-events-none"
|
||||
/>
|
||||
</ZoomPanArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── PDF viewer (pdf.js → canvas) ───────────────────────────────────────────────
|
||||
const PdfViewer = ({ blobUrl, fileName }) => {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [pdfDoc, setPdfDoc] = useState(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [numPages, setNumPages] = useState(1);
|
||||
const [rendering, setRendering] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const canvasRef = useRef(null);
|
||||
|
||||
const fit = () => { setScale(1); setOffset({ x: 0, y: 0 }); };
|
||||
|
||||
// ── Load the PDF document ──────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const pdfjsLib = await import('pdfjs-dist');
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||||
new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
|
||||
|
||||
const doc = await pdfjsLib.getDocument(blobUrl).promise;
|
||||
if (cancelled) return;
|
||||
setPdfDoc(doc);
|
||||
setNumPages(doc.numPages);
|
||||
} catch (err) {
|
||||
if (!cancelled) setLoadError(true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [blobUrl]);
|
||||
|
||||
// ── Render current page to canvas ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!pdfDoc) return;
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setRendering(true);
|
||||
try {
|
||||
const pdfPage = await pdfDoc.getPage(page);
|
||||
const viewport = pdfPage.getViewport({ scale: 1.5 }); // base render scale for crispness
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || cancelled) return;
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
await pdfPage.render({ canvasContext: ctx, viewport }).promise;
|
||||
} catch {
|
||||
if (!cancelled) setLoadError(true);
|
||||
} finally {
|
||||
if (!cancelled) setRendering(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [pdfDoc, page]);
|
||||
|
||||
if (loadError) return <UnsupportedMessage fileName={fileName} />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<ZoomToolbar
|
||||
scale={scale}
|
||||
onZoomIn={() => setScale((s) => Math.min(MAX_SCALE, +(s + SCALE_STEP).toFixed(2)))}
|
||||
onZoomOut={() => setScale((s) => Math.max(MIN_SCALE, +(s - SCALE_STEP).toFixed(2)))}
|
||||
onFit={fit}
|
||||
page={page}
|
||||
numPages={numPages}
|
||||
onPrevPage={() => { setPage((p) => Math.max(1, p - 1)); fit(); }}
|
||||
onNextPage={() => { setPage((p) => Math.min(numPages, p + 1)); fit(); }}
|
||||
/>
|
||||
<ZoomPanArea scale={scale} setScale={setScale} offset={offset} setOffset={setOffset} fitFn={fit}>
|
||||
<canvas ref={canvasRef} className="max-h-[380px] select-none" />
|
||||
</ZoomPanArea>
|
||||
{rendering && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Main viewer ────────────────────────────────────────────────────────────────
|
||||
const FileZoomViewer = ({ blobUrl, mimeType, fileName, loading }) => {
|
||||
const mode = resolveMode(mimeType, fileName);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[420px]">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!blobUrl || mode === 'unsupported') {
|
||||
return <UnsupportedMessage fileName={fileName} />;
|
||||
}
|
||||
|
||||
if (mode === 'image') return <ImageViewer blobUrl={blobUrl} fileName={fileName} />;
|
||||
if (mode === 'pdf') return <PdfViewer blobUrl={blobUrl} fileName={fileName} />;
|
||||
|
||||
return <UnsupportedMessage fileName={fileName} />;
|
||||
};
|
||||
|
||||
export default FileZoomViewer;
|
||||
@@ -0,0 +1,50 @@
|
||||
// components/LessonBlock.jsx
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { PreviewChrome, PreviewContent } from "@/modules/admin/components/courses/LessonsPreview";
|
||||
|
||||
function LessonSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-9 w-2/3" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-5/6" />
|
||||
<Skeleton className="w-full aspect-video rounded-xl" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* lesson — { id, title, blocks[] }
|
||||
* loading — true while fetch is in-flight
|
||||
*/
|
||||
const LessonBlock = ({ lesson, loading = false }) => {
|
||||
if (!lesson && !loading) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
|
||||
<p className="text-sm">Select a lesson to get started.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LessonSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PreviewChrome showChrome={false}>
|
||||
<div className="space-y-5">
|
||||
<PreviewContent
|
||||
lesson={lesson}
|
||||
blocks={lesson.blocks ?? []}
|
||||
empty="No content blocks yet."
|
||||
/>
|
||||
</div>
|
||||
</PreviewChrome>
|
||||
);
|
||||
};
|
||||
|
||||
export default LessonBlock;
|
||||
@@ -0,0 +1,248 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : TaskListTable.jsx
|
||||
* Type : Component (Client)
|
||||
* Description : Lightweight, read-only DataTable for the GroupList "List" view.
|
||||
* Client-side search + pagination over the currently-fetched
|
||||
* taskLists array (no admin DataTable dependency, no row actions,
|
||||
* no selection/bulk actions).
|
||||
*
|
||||
* Props:
|
||||
* taskLists {array} – array of TaskList objects (with .tasks[0])
|
||||
* loading {boolean} – show skeleton rows
|
||||
* onRefresh {function} – called when refresh button is clicked
|
||||
* onRowClick {function} – (taskList) => void, navigates to task list view
|
||||
* statusLabel {string} – 'ongoing' | 'done' | 'overdue' — drives badge style
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Search, RefreshCw, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight,
|
||||
ArrowRight, Check, AlertTriangle, Circle,
|
||||
} from 'lucide-react';
|
||||
import { formatDate } from '@/utils/table.util';
|
||||
|
||||
const PAGE_SIZES = [50, 100, 1000];
|
||||
|
||||
// ─── Status badge per bucket ───────────────────────────────────────────────────
|
||||
const StatusBadge = ({ status }) => {
|
||||
const map = {
|
||||
ongoing: { label: 'Ongoing', icon: Circle, className: 'bg-muted text-muted-foreground' },
|
||||
done: { label: 'Done', icon: Check, className: 'bg-green-500/10 text-green-700 dark:text-green-400' },
|
||||
overdue: { label: 'Overdue', icon: AlertTriangle, className: 'bg-destructive/10 text-destructive' },
|
||||
};
|
||||
const { label, icon: Icon, className } = map[status] ?? map.ongoing;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium rounded-full px-2.5 py-1 ${className}`}>
|
||||
<Icon className="size-3.5" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Skeleton rows ──────────────────────────────────────────────────────────────
|
||||
const SkeletonRows = ({ rows = 5 }) => (
|
||||
<>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-48" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-20 rounded-full" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-4" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
// ─── Main component ──────────────────────────────────────────────────────────────
|
||||
export default function ClientTaskListTable({
|
||||
taskLists = [],
|
||||
loading = false,
|
||||
onRefresh,
|
||||
onRowClick,
|
||||
statusLabel = 'ongoing',
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
|
||||
// ── Client-side search filter ─────────────────────────────────────────────
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return taskLists;
|
||||
const q = search.trim().toLowerCase();
|
||||
return taskLists.filter((tl) =>
|
||||
tl.name?.toLowerCase().includes(q) ||
|
||||
tl.description?.toLowerCase().includes(q)
|
||||
);
|
||||
}, [taskLists, search]);
|
||||
|
||||
// ── Pagination ─────────────────────────────────────────────────────────────
|
||||
const totalRecords = filtered.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRecords / pageSize));
|
||||
const clampedPage = Math.min(page, totalPages);
|
||||
|
||||
const pageRows = useMemo(() => {
|
||||
const start = (clampedPage - 1) * pageSize;
|
||||
return filtered.slice(start, start + pageSize);
|
||||
}, [filtered, clampedPage, pageSize]);
|
||||
|
||||
const handleSearchChange = (val) => {
|
||||
setSearch(val);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (val) => {
|
||||
setPageSize(Number(val));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const rangeStart = totalRecords === 0 ? 0 : (clampedPage - 1) * pageSize + 1;
|
||||
const rangeEnd = Math.min(clampedPage * pageSize, totalRecords);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
{/* ── Toolbar: search + refresh ──────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="relative max-w-xs flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search task lists..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Table ──────────────────────────────────────────────────────── */}
|
||||
<div className="rounded-lg border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/40 hover:bg-muted/40">
|
||||
<TableHead className="pl-6">Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Deadline</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-8" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<SkeletonRows />
|
||||
) : pageRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-12 text-sm text-muted-foreground">
|
||||
No task lists match your search.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
pageRows.map((tl) => {
|
||||
const task = tl.tasks?.[0];
|
||||
return (
|
||||
<TableRow
|
||||
key={tl.task_list_id}
|
||||
className="hover:bg-muted/30 cursor-pointer transition-colors"
|
||||
onClick={() => onRowClick?.(tl)}
|
||||
>
|
||||
<TableCell className="font-medium pl-6">{tl.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm max-w-xs truncate">
|
||||
{tl.description || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm whitespace-nowrap">
|
||||
{task?.deadline ? formatDate(task.deadline) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={statusLabel} />
|
||||
</TableCell>
|
||||
<TableCell className="pr-6">
|
||||
<ArrowRight className="size-4 text-muted-foreground/60" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Pagination ─────────────────────────────────────────────────── */}
|
||||
{!loading && totalRecords > 0 && (
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {rangeStart}-{rangeEnd} of {totalRecords}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Select value={String(pageSize)} onValueChange={handlePageSizeChange}>
|
||||
<SelectTrigger className="h-8 w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZES.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size} / page
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="outline" size="icon" className="size-8"
|
||||
onClick={() => setPage(1)}
|
||||
disabled={clampedPage === 1}
|
||||
aria-label="First page"
|
||||
>
|
||||
<ChevronsLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="icon" className="size-8"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={clampedPage === 1}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-sm px-2 whitespace-nowrap">
|
||||
{clampedPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline" size="icon" className="size-8"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={clampedPage === totalPages}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline" size="icon" className="size-8"
|
||||
onClick={() => setPage(totalPages)}
|
||||
disabled={clampedPage === totalPages}
|
||||
aria-label="Last page"
|
||||
>
|
||||
<ChevronsRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Sun, Moon, Monitor } from 'lucide-react'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const themeConfig = {
|
||||
light: { label: 'Light', icon: Sun },
|
||||
dark: { label: 'Dark', icon: Moon },
|
||||
system: { label: 'System', icon: Monitor },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 bg-card rounded-full border">
|
||||
{Object.entries(themeConfig).map(([key, { label, icon: Icon }]) => (
|
||||
<Tooltip key={key}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={() => setTheme(key)}
|
||||
aria-label={`Set theme to ${key}`}
|
||||
className={`p-1 rounded-full transition-colors ${
|
||||
theme === key ? 'border' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className="size-3.5"
|
||||
style={theme === key ? { fill: 'currentColor' } : { fill: 'none' }}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>{label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Trophy } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* course — { title, ... } the completed course
|
||||
*/
|
||||
const CourseCompleteBlock = ({ course }) => {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="rounded-xl border bg-card p-6 space-y-6 text-center sm:p-10">
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-amber-500/10 p-4">
|
||||
<Trophy className="size-10 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-2xl font-semibold sm:text-3xl">Congratulations!</h2>
|
||||
<p className="text-sm text-muted-foreground sm:text-base">
|
||||
You've successfully completed <span className="font-medium text-foreground">{course?.title}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-green-500/30 bg-green-500/5 px-4 py-3">
|
||||
<p className="text-sm font-medium text-green-700 dark:text-green-400">Course Complete</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
You've passed all required units and the final assessment for this course.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseCompleteBlock;
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import {
|
||||
CloudUpload,
|
||||
FileText,
|
||||
Image,
|
||||
Video,
|
||||
Paperclip,
|
||||
Plus,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
const DEFAULT_MAX_BYTES = 500 * 1024 * 1024; // 500 MB
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
function formatBytes(b) {
|
||||
if (b >= 1024 * 1024 * 1024) return (b / 1024 / 1024 / 1024).toFixed(1) + " GB";
|
||||
if (b >= 1024 * 1024) return (b / 1024 / 1024).toFixed(1) + " MB";
|
||||
return (b / 1024).toFixed(0) + " KB";
|
||||
}
|
||||
|
||||
function iconForFile(name) {
|
||||
const ext = name.split(".").pop().toLowerCase();
|
||||
if (["mp4", "mov", "avi", "webm"].includes(ext)) return "vid";
|
||||
if (["pdf", "doc", "docx", "pptx", "txt"].includes(ext)) return "doc";
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "svg"].includes(ext)) return "img";
|
||||
return "def";
|
||||
}
|
||||
|
||||
const FILE_ICON_MAP = {
|
||||
vid: { icon: Video, bg: "bg-amber-100 dark:bg-amber-900", text: "text-amber-700 dark:text-amber-300" },
|
||||
doc: { icon: FileText, bg: "bg-blue-100 dark:bg-blue-900", text: "text-blue-700 dark:text-blue-300" },
|
||||
img: { icon: Image, bg: "bg-green-100 dark:bg-green-900", text: "text-green-700 dark:text-green-300" },
|
||||
def: { icon: Paperclip, bg: "bg-muted", text: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
// ── FileIcon ──────────────────────────────────────────────────────────────────
|
||||
const FileIcon = ({ type }) => {
|
||||
const { icon: Icon, bg, text } = FILE_ICON_MAP[type] ?? FILE_ICON_MAP.def;
|
||||
return (
|
||||
<div className={cn("w-8 h-8 rounded-md flex items-center justify-center shrink-0", bg)}>
|
||||
<Icon className={cn("size-4", text)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── FileItem ──────────────────────────────────────────────────────────────────
|
||||
const FileItem = ({ file, onRemove }) => {
|
||||
const isUploading = file.status === "uploading";
|
||||
const isError = file.status === "error";
|
||||
const isDone = file.status === "done";
|
||||
|
||||
return (
|
||||
<div className="border rounded-md px-3 py-2.5 bg-muted/50 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileIcon type={file.type} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{file.size}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isDone && <span className="text-xs text-green-600 dark:text-green-400 font-medium">Uploaded</span>}
|
||||
{isError && <span className="text-xs text-destructive font-medium">Failed</span>}
|
||||
{isUploading && <span className="text-xs text-blue-600 dark:text-blue-400">{file.progress}%</span>}
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors p-0.5 rounded"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isUploading && <Progress value={file.progress} className="h-1" />}
|
||||
{isError && <Progress value={100} className="h-1 [&>div]:bg-destructive" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── StorageBar ────────────────────────────────────────────────────────────────
|
||||
const StorageBar = ({ usedBytes, maxBytes }) => {
|
||||
const pct = Math.min(100, (usedBytes / maxBytes) * 100);
|
||||
const isOver = usedBytes > maxBytes;
|
||||
const isWarn = pct > 75 && !isOver;
|
||||
|
||||
return (
|
||||
<div className="mt-3 p-4 border rounded-md bg-muted/50 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
<Database className="size-4" /> Total size
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-sm font-medium",
|
||||
isOver && "text-destructive",
|
||||
isWarn && "text-amber-600 dark:text-amber-400",
|
||||
!isOver && !isWarn && "text-muted-foreground"
|
||||
)}>
|
||||
{formatBytes(usedBytes)} of {formatBytes(maxBytes)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={pct}
|
||||
className={cn(
|
||||
"h-1.5",
|
||||
isOver && "[&>div]:bg-destructive",
|
||||
isWarn && "[&>div]:bg-amber-500"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── FileUpload ────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Standalone file upload UI — no modal, no footer buttons.
|
||||
* Compose inside <ResponsiveModal> or any container.
|
||||
*
|
||||
* Props:
|
||||
* maxBytes {number} – total size cap (default: 500 MB)
|
||||
* accept {string} – native <input accept> string (fallback if
|
||||
* allowedFileTypes not provided)
|
||||
* hint {string} – dropzone helper text (fallback if
|
||||
* allowedFileTypes/maxFileCount not provided)
|
||||
* allowedFileTypes {string[]} – e.g. ["PDF","DOCX","PNG","JPG","MP4"], from the
|
||||
* task's upload_file requirement. Drives both
|
||||
* the displayed hint and the <input accept>,
|
||||
* and is enforced client-side on file add.
|
||||
* maxFileCount {number} – max number of files allowed. Displayed in
|
||||
* the hint and enforced client-side on file add.
|
||||
* onChange {function} – fires on every file list change:
|
||||
* ({ files, isUploading, isOverLimit }) => void
|
||||
* onUploadDone {function} – fires when all uploads finish:
|
||||
* ({ files }) => void
|
||||
*/
|
||||
const FileUpload = ({
|
||||
maxBytes = DEFAULT_MAX_BYTES,
|
||||
accept,
|
||||
hint = "PDF, DOCX, MP4, PNG, JPG",
|
||||
allowedFileTypes,
|
||||
maxFileCount,
|
||||
onChange,
|
||||
onUploadDone,
|
||||
}) => {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const totalBytes = files.reduce((sum, f) => sum + (f.bytes || 0), 0);
|
||||
const isOverLimit = totalBytes > maxBytes;
|
||||
const isUploading = files.some((f) => f.status === "uploading");
|
||||
|
||||
// ── Derived accept string ───────────────────────────────────────────────────
|
||||
const derivedAccept = allowedFileTypes?.length
|
||||
? allowedFileTypes.map((ext) => `.${ext.toLowerCase()}`).join(",")
|
||||
: accept;
|
||||
|
||||
// ── Derived hint text ────────────────────────────────────────────────────────
|
||||
const derivedHint = (() => {
|
||||
if (!allowedFileTypes?.length && !maxFileCount) return hint;
|
||||
const parts = [];
|
||||
if (allowedFileTypes?.length) {
|
||||
parts.push(allowedFileTypes.map((e) => e.toUpperCase()).join(", "));
|
||||
}
|
||||
if (maxFileCount) {
|
||||
parts.push(`Max ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
})();
|
||||
|
||||
// Notify parent with full state
|
||||
const notify = (next) => {
|
||||
const uploading = next.some((f) => f.status === "uploading");
|
||||
const overLimit = next.reduce((s, f) => s + (f.bytes || 0), 0) > maxBytes;
|
||||
onChange?.({ files: next, isUploading: uploading, isOverLimit: overLimit });
|
||||
};
|
||||
|
||||
// ── simulate upload progress ──────────────────────────────────────────────
|
||||
const simulateUpload = useCallback((id) => {
|
||||
const tick = () => {
|
||||
setFiles((prev) => {
|
||||
const idx = prev.findIndex((f) => f.id === id);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
const entry = { ...next[idx] };
|
||||
entry.progress = Math.min(100, entry.progress + Math.floor(Math.random() * 18 + 8));
|
||||
if (entry.progress >= 100) { entry.progress = 100; entry.status = "done"; }
|
||||
next[idx] = entry;
|
||||
notify(next);
|
||||
const allDone = next.every((f) => f.status !== "uploading");
|
||||
if (allDone) onUploadDone?.({ files: next });
|
||||
return next;
|
||||
});
|
||||
setFiles((prev) => {
|
||||
const f = prev.find((f) => f.id === id);
|
||||
if (f && f.status === "uploading") setTimeout(tick, 250 + Math.random() * 200);
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
setTimeout(tick, 300);
|
||||
}, [onChange, onUploadDone, maxBytes]);
|
||||
|
||||
// ── add files ─────────────────────────────────────────────────────────────
|
||||
const addFiles = useCallback((rawFiles) => {
|
||||
let incoming = Array.from(rawFiles);
|
||||
|
||||
// ── Validate allowed file types ─────────────────────────────────────────
|
||||
if (allowedFileTypes?.length) {
|
||||
const allowed = allowedFileTypes.map((e) => e.toUpperCase());
|
||||
const rejected = [];
|
||||
incoming = incoming.filter((f) => {
|
||||
const ext = (f.name.split(".").pop() ?? "").toUpperCase();
|
||||
const ok = allowed.includes(ext);
|
||||
if (!ok) rejected.push(f.name);
|
||||
return ok;
|
||||
});
|
||||
if (rejected.length > 0) {
|
||||
setTimeout(() => toast.error(
|
||||
`${rejected.length === 1 ? `"${rejected[0]}" is` : `${rejected.length} files are`} not allowed. ` +
|
||||
`Accepted types: ${allowed.join(", ")}.`
|
||||
), 0);
|
||||
}
|
||||
}
|
||||
|
||||
setFiles((prev) => {
|
||||
// ── Validate max file count ───────────────────────────────────────────
|
||||
if (maxFileCount) {
|
||||
const availableSlots = maxFileCount - prev.length;
|
||||
if (availableSlots <= 0) {
|
||||
setTimeout(() => toast.error(
|
||||
`You can only attach up to ${maxFileCount} file${maxFileCount !== 1 ? "s" : ""}.`
|
||||
), 0);
|
||||
incoming = [];
|
||||
} else if (incoming.length > availableSlots) {
|
||||
setTimeout(() => toast.error(
|
||||
`Only ${availableSlots} more file${availableSlots !== 1 ? "s" : ""} can be added ` +
|
||||
`(max ${maxFileCount}).`
|
||||
), 0);
|
||||
incoming = incoming.slice(0, availableSlots);
|
||||
}
|
||||
}
|
||||
|
||||
const duplicates = [];
|
||||
const toAdd = [];
|
||||
incoming.forEach((f) => {
|
||||
if (prev.some((e) => e.name === f.name)) {
|
||||
duplicates.push(f.name);
|
||||
} else {
|
||||
toAdd.push({
|
||||
id: crypto.randomUUID(),
|
||||
name: f.name,
|
||||
size: formatBytes(f.size),
|
||||
bytes: f.size,
|
||||
type: iconForFile(f.name),
|
||||
progress: 0,
|
||||
status: "uploading",
|
||||
rawFile: f,
|
||||
});
|
||||
}
|
||||
});
|
||||
if (duplicates.length > 0) {
|
||||
setTimeout(() => toast.error(
|
||||
duplicates.length === 1
|
||||
? `"${duplicates[0]}" is already attached.`
|
||||
: `${duplicates.length} files are already attached.`
|
||||
), 0);
|
||||
}
|
||||
const next = prev.concat(toAdd);
|
||||
toAdd.forEach((e) => simulateUpload(e.id));
|
||||
notify(next);
|
||||
return next;
|
||||
});
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}, [simulateUpload, onChange, maxBytes, allowedFileTypes, maxFileCount]);
|
||||
|
||||
// ── remove ────────────────────────────────────────────────────────────────
|
||||
const removeFile = (id) => {
|
||||
setFiles((prev) => {
|
||||
const next = prev.filter((f) => f.id !== id);
|
||||
notify(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ── drag & drop ───────────────────────────────────────────────────────────
|
||||
const onDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
|
||||
const onDragLeave = () => setIsDragging(false);
|
||||
const onDrop = (e) => { e.preventDefault(); setIsDragging(false); addFiles(e.dataTransfer.files); };
|
||||
|
||||
const canAttachMore = !maxFileCount || files.length < maxFileCount;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0">
|
||||
|
||||
{/* Dropzone */}
|
||||
{files.length === 0 && (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors",
|
||||
isDragging
|
||||
? "border-blue-400 bg-blue-50 dark:bg-blue-950/30"
|
||||
: "border-border hover:border-blue-400 hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
<CloudUpload className={cn("size-8 mx-auto mb-3", isDragging ? "text-blue-500" : "text-muted-foreground")} />
|
||||
<p className="text-sm font-medium">Drop files here or click to browse</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">Upload your work to submit with this task</p>
|
||||
<p className="text-sm mt-2">{derivedHint} · Max total: {formatBytes(maxBytes)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm font-medium text-muted-foreground">Attached files</span>
|
||||
{canAttachMore && (
|
||||
<Button onClick={() => fileInputRef.current?.click()} variant="outline">
|
||||
<Plus /> Attach
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{files.length >= 2 ? (
|
||||
<ScrollArea className="h-[210px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
{files.map((f) => (
|
||||
<FileItem key={f.id} file={f} onRemove={() => removeFile(f.id)} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{files.map((f) => (
|
||||
<FileItem key={f.id} file={f} onRemove={() => removeFile(f.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-2">{derivedHint}</p>
|
||||
|
||||
<StorageBar usedBytes={totalBytes} maxBytes={maxBytes} />
|
||||
|
||||
{isOverLimit && (
|
||||
<div className="mt-2.5 flex items-start gap-2 px-3 py-2.5 rounded-md bg-destructive/10 border border-destructive/30">
|
||||
<AlertTriangle className="size-4 text-destructive shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-destructive leading-relaxed">
|
||||
Total file size exceeds the <strong>{formatBytes(maxBytes)}</strong> limit.
|
||||
Please remove some files before turning in.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={derivedAccept}
|
||||
className="hidden"
|
||||
onChange={(e) => addFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
export { FileUpload, FileItem, FileIcon, StorageBar, formatBytes, iconForFile };
|
||||
@@ -0,0 +1,284 @@
|
||||
// components/QuizBlock.jsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
ChevronLeft, ChevronRight,
|
||||
Circle, CheckCircle2,
|
||||
Square, CheckSquare2,
|
||||
} from "lucide-react";
|
||||
|
||||
function QuizSkeleton() {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-5">
|
||||
<Skeleton className="h-5 w-1/3" />
|
||||
<Skeleton className="h-1.5 w-full rounded-full" />
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-11 w-full rounded-lg" />
|
||||
<Skeleton className="h-11 w-full rounded-lg" />
|
||||
<Skeleton className="h-11 w-full rounded-lg" />
|
||||
<Skeleton className="h-11 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Props:
|
||||
* quiz — { quiz_id, title, is_required, passing_score, max_questions, attempt_count, has_passed, best_attempt, questions: [...] }
|
||||
* loading — true while fetch is in-flight
|
||||
* onSubmit — (answers) => Promise<result|null>
|
||||
* label — noun used in copy ("Quiz" or "Assessment"), default "Quiz"
|
||||
*/
|
||||
const QuizBlock = ({ quiz, loading = false, onSubmit, onRetake, label = "Quiz" }) => {
|
||||
const questions = quiz?.questions ?? [];
|
||||
const total = questions.length;
|
||||
|
||||
const [stage, setStage] = useState("intro"); // 'intro' | 'taking' | 'result'
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
setStage("intro");
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setResult(null);
|
||||
}, [quiz?.quiz_id]);
|
||||
|
||||
if (!quiz && !loading) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
|
||||
<p className="text-sm">No {label.toLowerCase()} available yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <QuizSkeleton />;
|
||||
|
||||
if (!total) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center text-muted-foreground py-20">
|
||||
<p className="text-sm">This {label.toLowerCase()} doesn't have any questions yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRetake = () => {
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setResult(null);
|
||||
setStage("intro");
|
||||
onRetake?.(); // refetch so attempts_remaining/cooldown_until reflect the submission that just happened
|
||||
};
|
||||
|
||||
// ── Intro screen ─────────────────────────────────────────────────────────
|
||||
if (stage === "intro") {
|
||||
const attempts = quiz.attempt_count ?? 0;
|
||||
const attemptsRemaining = quiz.attempts_remaining ?? null; // null = backend hasn't sent this field yet
|
||||
const cooldownUntil = quiz.cooldown_until ? new Date(quiz.cooldown_until) : null;
|
||||
const canAttempt = quiz.can_attempt ?? true; // default open if field is absent, for back-compat
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="rounded-xl border bg-card p-6 space-y-6 text-center sm:p-10 shadow-lg">
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-xl font-semibold sm:text-2xl">{quiz.title || label}</h2>
|
||||
{quiz.is_required && (
|
||||
<span className="inline-block rounded-full bg-amber-500/10 px-2.5 py-0.5 text-xs font-medium text-amber-600">
|
||||
Required to complete this {label === "Assessment" ? "course" : "unit"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quiz.has_passed && (
|
||||
<div className="rounded-lg border border-green-500/30 bg-green-500/5 px-4 py-3">
|
||||
<p className="text-md font-medium text-green-700 dark:text-green-400">
|
||||
You've already passed this {label.toLowerCase()}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
Best score: {quiz.best_attempt?.score}%
|
||||
{quiz.best_attempt?.passing_score != null && (
|
||||
<span className="text-muted-foreground"> · passing score {quiz.best_attempt.passing_score}%</span>
|
||||
)}
|
||||
</p>
|
||||
{quiz.best_attempt?.attempt_number != null && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Passed on attempt #{quiz.best_attempt.attempt_number}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canAttempt && cooldownUntil && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
|
||||
<p className="text-md font-medium text-amber-700 dark:text-amber-400">You're on cooldown</p>
|
||||
<p className="text-sm">
|
||||
You can retake this {label.toLowerCase()} after {cooldownUntil.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canAttempt && !cooldownUntil && attemptsRemaining === 0 && (
|
||||
<div className="rounded-lg border border-red-500/30 bg-red-500/5 px-4 py-3">
|
||||
<p className="text-md font-medium text-red-700 dark:text-red-400">No attempts remaining</p>
|
||||
<p className="text-sm">
|
||||
{quiz.window_reset_at
|
||||
? `You can try again after ${new Date(quiz.window_reset_at).toLocaleString()}`
|
||||
: `You've used all available attempts for this ${label.toLowerCase()}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-6 sm:gap-10">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-2xl font-bold sm:text-3xl">{total}</p>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">Question{total === 1 ? "" : "s"}</p>
|
||||
</div>
|
||||
<div className="h-10 w-px bg-border" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-2xl font-bold sm:text-3xl">
|
||||
{attemptsRemaining !== null ? attemptsRemaining : attempts}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||
{attemptsRemaining !== null ? "Attempts Left" : `Attempt${attempts === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-10 w-px bg-border" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-2xl font-bold sm:text-3xl">{quiz.passing_score}%</p>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">To pass</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="lg" className="w-full sm:w-auto" onClick={() => setStage("taking")} disabled={!canAttempt}>
|
||||
{attempts > 0 ? `Retake ${label}` : `Start ${label}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Results view ─────────────────────────────────────────────────────────
|
||||
if (stage === "result" && result) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-5">
|
||||
<div className={`rounded-xl border p-5 text-center space-y-1 ${result.passed ? "border-green-500/30 bg-green-500/5" : "border-red-500/30 bg-red-500/5"}`}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.passed ? "You passed!" : "You did not pass"}
|
||||
</p>
|
||||
<p className="text-3xl font-bold">{result.score}%</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{result.earned_points} / {result.total_points} points · passing score {result.passing_score}%
|
||||
</p>
|
||||
{result.attempt_number && (
|
||||
<p className="text-xs text-muted-foreground">Attempt #{result.attempt_number}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!result.passed && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={handleRetake}>Retake {label}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Question stepper ─────────────────────────────────────────────────────
|
||||
const question = questions[currentIndex];
|
||||
const isFirst = currentIndex === 0;
|
||||
const isLast = currentIndex === total - 1;
|
||||
const isMulti = question.type === "multi_select";
|
||||
const selected = answers[question.question_id];
|
||||
const progress = Math.round(((currentIndex + 1) / total) * 100);
|
||||
|
||||
const handleOptionClick = (optionId) => {
|
||||
setAnswers((prev) => {
|
||||
if (!isMulti) return { ...prev, [question.question_id]: optionId };
|
||||
const current = prev[question.question_id] ?? [];
|
||||
const next = current.includes(optionId)
|
||||
? current.filter((id) => id !== optionId)
|
||||
: [...current, optionId];
|
||||
return { ...prev, [question.question_id]: next };
|
||||
});
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isLast) {
|
||||
setSubmitting(true);
|
||||
const res = await onSubmit?.(answers);
|
||||
setSubmitting(false);
|
||||
if (res) {
|
||||
setResult(res);
|
||||
setStage("result");
|
||||
}
|
||||
return;
|
||||
}
|
||||
setCurrentIndex((i) => Math.min(i + 1, total - 1));
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (isFirst) { setStage("intro"); return; }
|
||||
setCurrentIndex((i) => Math.max(i - 1, 0));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-5">
|
||||
<div className="space-y-2">
|
||||
{quiz.title && <h2 className="text-lg font-semibold sm:text-xl">{quiz.title}</h2>}
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground sm:text-sm">
|
||||
<span>Question {currentIndex + 1} of {total}</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-border overflow-hidden">
|
||||
<div className="h-full bg-primary transition-all duration-300 ease-out" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4 space-y-4 sm:p-6">
|
||||
<p className="font-bold text-base leading-relaxed sm:text-lg">
|
||||
{currentIndex + 1}. {question.question}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{(question.options ?? []).map((option, i) => {
|
||||
const letter = String.fromCharCode(65 + i);
|
||||
const isSelected = isMulti
|
||||
? (selected ?? []).includes(option.option_id)
|
||||
: selected === option.option_id;
|
||||
const Icon = isMulti
|
||||
? (isSelected ? CheckSquare2 : Square)
|
||||
: (isSelected ? CheckCircle2 : Circle);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.option_id}
|
||||
type="button"
|
||||
onClick={() => handleOptionClick(option.option_id)}
|
||||
className={`flex w-full items-center gap-3 rounded-lg border px-3 py-2.5 text-left text-sm transition-colors sm:text-base ${isSelected ? "border-primary bg-primary/5" : "border-border hover:bg-muted-foreground/5"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`size-4 shrink-0 ${isSelected ? "text-primary" : "text-muted-foreground"}`} />
|
||||
<span className="font-medium text-muted-foreground">{letter}.</span>
|
||||
<span>{option.text}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={handlePrev} disabled={submitting}>
|
||||
<ChevronLeft className="size-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button onClick={handleNext} disabled={submitting}>
|
||||
{submitting ? "Submitting..." : isLast ? "Submit" : "Next"}
|
||||
{!isLast && !submitting && <ChevronRight className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuizBlock;
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { BookOpen, Tag, CheckCheck, RefreshCcw } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SendHorizonal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
const SUB_LABEL = { free: 'Free', premium: 'Premium', exclusive: 'Exclusive' };
|
||||
const LVL_LABEL = { beginner: 'Beginner', intermediate: 'Intermediate', advanced: 'Advanced' };
|
||||
|
||||
const ReadCourse = ({ title = "Read Course", courses = [] }) => {
|
||||
const navigate = useNavigate();
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [details, setDetails] = useState({});
|
||||
const [summaries, setSummaries] = useState({});
|
||||
const prevCompletedRef = useRef({});
|
||||
|
||||
// Toast notification when a course requirement is auto turned-in
|
||||
useEffect(() => {
|
||||
courses.forEach((course) => {
|
||||
const prev = prevCompletedRef.current[course.id];
|
||||
if (course.completed && prev === false) {
|
||||
toast.success(`"${course.title}" has been automatically turned in!`);
|
||||
}
|
||||
prevCompletedRef.current[course.id] = !!course.completed;
|
||||
});
|
||||
}, [courses]);
|
||||
|
||||
useEffect(() => {
|
||||
courses.forEach(async (course) => {
|
||||
if (!course.reference_id) return;
|
||||
try {
|
||||
const res = await api.get(`/client/courses/uuid/${course.reference_id}`);
|
||||
const d = res.data?.data;
|
||||
if (!d) return;
|
||||
setDetails((prev) => ({ ...prev, [course.reference_id]: d }));
|
||||
|
||||
const progRes = await api.get(`/client/courses/${d.course_id}/progress/summary`);
|
||||
const summary = progRes.data?.data;
|
||||
if (summary) setSummaries((prev) => ({ ...prev, [course.reference_id]: summary }));
|
||||
} catch (err) {
|
||||
console.error('[ReadCourse] fetch failed:', err?.response?.status, err?.message);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Reading percentage (lessons read / total) — independent of quiz / assessment completion
|
||||
const getReadingPercent = (course) => {
|
||||
const summary = summaries[course.reference_id];
|
||||
return summary ? summary.percent : (course.progress ?? 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2">
|
||||
<BookOpen className="size-4 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-sm">{title}</h2>
|
||||
<Badge variant="secondary" className="ml-auto">{courses.length}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Horizontal scroll */}
|
||||
<ScrollArea className="w-full bg-muted overflow-hidden">
|
||||
<div className="flex gap-4 p-4">
|
||||
{courses.map((course) => {
|
||||
const info = details[course.reference_id];
|
||||
const percent = getReadingPercent(course);
|
||||
// task requirement completed (auto turned-in) — quizzes + assessment also done
|
||||
const done = !!course.completed;
|
||||
// all lessons read but task not yet auto-turned-in (quiz/assessment still pending)
|
||||
const allRead = !done && percent >= 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={course.id}
|
||||
onClick={() => setSelected(course)}
|
||||
className="bg-card rounded-2xl border dark:hover:border-blue-500 p-4 flex flex-col gap-2.5 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0"
|
||||
>
|
||||
<div className="flex gap-2 items-center flex-wrap">
|
||||
{info ? (
|
||||
<>
|
||||
{info.subscription && (
|
||||
<Badge variant="secondary">
|
||||
<Tag className="size-3" /> {SUB_LABEL[info.subscription] ?? info.subscription}
|
||||
</Badge>
|
||||
)}
|
||||
{info.level && (
|
||||
<Badge variant="secondary">
|
||||
<Tag className="size-3" /> {LVL_LABEL[info.level] ?? info.level}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
) : course.reference_id ? (
|
||||
<Badge variant="secondary" className="opacity-40 text-xs">Loading…</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h1 className="text-lg font-medium leading-snug line-clamp-3 group-hover:text-blue-700 dark:group-hover:text-blue-400 transition-colors">
|
||||
{course.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{info?.description ?? ''}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 mt-auto pt-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{done
|
||||
? <><CheckCheck className="size-4 text-green-500" /> Completed</>
|
||||
: allRead
|
||||
? <><CheckCheck className="size-4 text-amber-500" /> Lessons Done</>
|
||||
: <><RefreshCcw className="size-4 text-muted-foreground" /> In Progress</>
|
||||
}
|
||||
</span>
|
||||
<span className="font-medium">{percent}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={percent}
|
||||
className={`h-1.5 ${done ? "[&>div]:bg-green-500" : allRead ? "[&>div]:bg-amber-500" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
|
||||
{/* Detail modal */}
|
||||
{selected && (() => {
|
||||
const info = details[selected.reference_id];
|
||||
const percent = getReadingPercent(selected);
|
||||
const done = !!selected.completed;
|
||||
const allRead = !done && percent >= 100;
|
||||
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={!!selected}
|
||||
onOpenChange={(o) => !o && setSelected(null)}
|
||||
title={selected.title}
|
||||
description={done ? "Course Summary" : "Course Info"}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSelected(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (info?.course_id) navigate(`/course/${info.course_id}/unit`, { state: allRead ? { seekFirstIncomplete: true } : undefined });
|
||||
}}
|
||||
disabled={done || !info?.course_id}
|
||||
>
|
||||
<SendHorizonal /> Proceed
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{done ? (
|
||||
<div className="flex items-center gap-2 bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg px-4 py-3 text-sm font-medium">
|
||||
<CheckCheck className="size-4 shrink-0" />
|
||||
Automatically Turned-in
|
||||
</div>
|
||||
) : allRead ? (
|
||||
<div className="flex items-center gap-2 bg-amber-500/10 text-amber-600 dark:text-amber-400 rounded-lg px-4 py-3 text-sm font-medium">
|
||||
<CheckCheck className="size-4 shrink-0" />
|
||||
Lessons complete — finish quizzes & assessment to turn in
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-lg px-4 py-3 text-sm font-medium">
|
||||
<RefreshCcw className="size-4 shrink-0" />
|
||||
Currently in progress
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 divide-x rounded-lg border text-center text-sm">
|
||||
<div className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xl font-bold">
|
||||
{info?.subscription ? (SUB_LABEL[info.subscription] ?? info.subscription) : '—'}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">Subscription</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 py-4">
|
||||
<span className="text-xl font-bold">
|
||||
{info?.level ? (LVL_LABEL[info.level] ?? info.level) : '—'}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">Level</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wide font-medium">
|
||||
About this course
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed">{info?.description ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadCourse;
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { FileVideo, Tag, CheckCheck } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
const ReadLesson = ({ title = "Read Lessons", lessons = [], groupId, taskListId, taskId }) => {
|
||||
const navigate = useNavigate();
|
||||
const [details, setDetails] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
lessons.forEach(async (lesson) => {
|
||||
if (!lesson.reference_id) return;
|
||||
try {
|
||||
const res = await api.get(`/client/courses/lesson/uuid/${lesson.reference_id}`);
|
||||
const d = res.data?.data;
|
||||
if (d) setDetails((prev) => ({ ...prev, [lesson.reference_id]: d }));
|
||||
} catch (err) {
|
||||
console.error('[ReadLesson] fetch failed:', err?.response?.status, err?.message);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2">
|
||||
<FileVideo className="size-4 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-sm">{title}</h2>
|
||||
<Badge variant="secondary" className="ml-auto">{lessons.length}</Badge>
|
||||
</div>
|
||||
<ScrollArea className="w-full bg-muted overflow-hidden">
|
||||
<div className="flex gap-4 p-4">
|
||||
{lessons.map((lesson) => {
|
||||
const info = details[lesson.reference_id];
|
||||
const progress = lesson.completed ? 100 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lesson.id}
|
||||
onClick={() => navigate(
|
||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
||||
{ state: { lesson } },
|
||||
)}
|
||||
className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0"
|
||||
>
|
||||
<Badge variant="secondary" className="w-fit">
|
||||
<Tag className="size-3" /> Lesson
|
||||
</Badge>
|
||||
<h1 className="text-base font-medium leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-card-foreground transition-colors">
|
||||
{lesson.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{info?.description ?? ''}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 mt-auto pt-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
{lesson.completed ? (
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium">
|
||||
<CheckCheck className="size-4" /> Completed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground font-medium">Not Started</span>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={progress}
|
||||
className={`h-1.5 ${lesson.completed ? "[&>div]:bg-green-500" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadLesson;
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Layers, Tag, CheckCheck, RefreshCw, SendHorizonal } from "lucide-react";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "@/utils/api.util";
|
||||
|
||||
const ReadUnit = ({ title = "Read Units", units = [], groupId, taskListId, taskId }) => {
|
||||
const navigate = useNavigate();
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [details, setDetails] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
units.forEach(async (unit) => {
|
||||
if (!unit.reference_id) return;
|
||||
try {
|
||||
const res = await api.get(`/client/courses/unit/uuid/${unit.reference_id}`);
|
||||
const d = res.data?.data;
|
||||
if (d) setDetails((prev) => ({ ...prev, [unit.reference_id]: d }));
|
||||
} catch (err) {
|
||||
console.error('[ReadUnit] fetch failed:', err?.response?.status, err?.message);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getProgress = (unit) => unit.completed ? 100 : (unit.progress ?? 0);
|
||||
|
||||
if (!units.length) {
|
||||
return (
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2">
|
||||
<Layers className="size-4 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-sm">{title}</h2>
|
||||
<Badge variant="secondary" className="ml-auto">0</Badge>
|
||||
</div>
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">No units available.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2">
|
||||
<Layers className="size-4 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-sm">{title}</h2>
|
||||
<Badge variant="secondary" className="ml-auto">{units.length}</Badge>
|
||||
</div>
|
||||
<ScrollArea className="w-full bg-muted overflow-hidden">
|
||||
<div className="flex gap-4 p-4">
|
||||
{units.map((unit) => {
|
||||
const info = details[unit.reference_id];
|
||||
const progress = getProgress(unit);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={unit.id}
|
||||
onClick={() => setSelected(unit)}
|
||||
className="bg-card rounded-2xl border p-4 flex flex-col gap-2.5 hover:bg-muted/60 hover:shadow-sm transition-all cursor-pointer group w-96 shrink-0"
|
||||
>
|
||||
<Badge variant="secondary" className="w-fit truncate max-w-full">
|
||||
<Tag className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{info ? (info.course?.title ?? 'No course') : 'Loading…'}
|
||||
</span>
|
||||
</Badge>
|
||||
<h1 className="text-base font-medium leading-snug line-clamp-2 group-hover:text-blue-700 dark:group-hover:text-card-foreground transition-colors">
|
||||
{unit.title}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{info?.description ?? ''}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3 mt-auto pt-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
{progress >= 100 ? (
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400 font-medium">
|
||||
<CheckCheck className="size-4" /> Completed
|
||||
</span>
|
||||
) : progress > 0 ? (
|
||||
<span className="flex items-center gap-1 font-medium">
|
||||
<RefreshCw className="size-4" /> In Progress
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground font-medium">Not Started</span>
|
||||
)}
|
||||
</div>
|
||||
<Progress
|
||||
value={progress}
|
||||
className={`h-1.5 ${progress >= 100 ? "[&>div]:bg-green-500" : ""}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<ResponsiveModal
|
||||
open={!!selected}
|
||||
onOpenChange={(v) => !v && setSelected(null)}
|
||||
title={selected?.title}
|
||||
description="Unit Info"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setSelected(null)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => navigate(
|
||||
`/group/${groupId}/view/${taskListId}/task/${taskId}/requirement`,
|
||||
{ state: { unit: selected } },
|
||||
)}
|
||||
disabled={getProgress(selected ?? {}) >= 100}
|
||||
>
|
||||
<SendHorizonal /> Proceed
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{selected && (() => {
|
||||
const info = details[selected.reference_id];
|
||||
const progress = getProgress(selected);
|
||||
const done = progress >= 100;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{done && (
|
||||
<div className="flex items-center gap-2 bg-green-50 dark:bg-green-950/40 text-green-700 dark:text-green-400 text-sm font-medium rounded-lg px-4 py-3">
|
||||
<CheckCheck className="size-4 shrink-0" />
|
||||
Automatically Turned-in
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info?.course?.title && (
|
||||
<p className="text-sm">
|
||||
<span className="text-muted-foreground">Course: </span>
|
||||
<span className="font-medium">{info.course.title}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground font-medium">
|
||||
About this unit
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed">{info?.description ?? '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadUnit;
|
||||
@@ -0,0 +1,173 @@
|
||||
import { ExternalLink, CheckCheck } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { useEffect, useState } from "react";
|
||||
import ResponsiveModal from "@/components/generic/ResponsiveModal";
|
||||
import { SendHorizonal } from "lucide-react";
|
||||
|
||||
// ── Meta fetcher ──────────────────────────────────────────────────────────────
|
||||
const fetchLinkMeta = async (url) => {
|
||||
try {
|
||||
const res = await fetch(`https://api.microlink.io/?url=${encodeURIComponent(url)}`);
|
||||
const json = await res.json();
|
||||
if (json.status === "success") {
|
||||
return {
|
||||
title: json.data.title ?? null,
|
||||
description: json.data.description ?? null,
|
||||
image: json.data.image?.url ?? json.data.logo?.url ?? null,
|
||||
};
|
||||
}
|
||||
} catch { /* silently fail */ }
|
||||
return { title: null, description: null, image: null };
|
||||
};
|
||||
|
||||
// ── LinkCard ──────────────────────────────────────────────────────────────────
|
||||
const LinkCard = ({ link, visited, onTurnIn, submitting }) => {
|
||||
const [meta, setMeta] = useState({ title: null, description: null, image: null });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!link.url) return;
|
||||
fetchLinkMeta(link.url)
|
||||
.then((data) => setMeta(data))
|
||||
.finally(() => setLoading(false));
|
||||
}, [link.url]);
|
||||
|
||||
const displayImage = meta.image ?? `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`;
|
||||
const displayTitle = meta.title ?? link.label;
|
||||
const displayDescription = meta.description ?? link.url;
|
||||
|
||||
const handleTurnIn = async () => {
|
||||
await onTurnIn(link.requirement_id);
|
||||
setModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="relative w-72 shrink-0 pt-0">
|
||||
{loading ? (
|
||||
<div className="relative z-20 h-40 w-full rounded-t-lg bg-muted animate-pulse" />
|
||||
) : (
|
||||
<img
|
||||
src={displayImage}
|
||||
alt={displayTitle}
|
||||
className="relative z-20 h-40 w-full object-cover rounded-t-lg"
|
||||
onError={(e) => {
|
||||
e.currentTarget.src = `https://avatar.vercel.sh/${encodeURIComponent(link.url)}`;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CardHeader>
|
||||
<CardTitle className="line-clamp-1">
|
||||
{loading
|
||||
? <span className="block h-4 w-32 bg-muted animate-pulse rounded" />
|
||||
: displayTitle
|
||||
}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate text-xs">
|
||||
{loading
|
||||
? <span className="block h-3 w-48 bg-muted animate-pulse rounded" />
|
||||
: displayDescription
|
||||
}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
{visited ? (
|
||||
<Button className="w-full" variant="secondary" disabled>
|
||||
<CheckCheck className="size-4" />
|
||||
Visited
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="w-full" onClick={() => setModalOpen(true)}>
|
||||
Visit Link
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<ResponsiveModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
title={`Visit Link: ${displayTitle}`}
|
||||
description={`By visiting a link, you are about to explore it then Turn-in after.`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleTurnIn} disabled={submitting}>
|
||||
<SendHorizonal /> {submitting ? "Submitting…" : "Turn In"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground break-all">{link.url}</p>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="size-4" />
|
||||
Open Link
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ── VisitLink ─────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Props:
|
||||
* title {string} – section title
|
||||
* links {array} – [{ requirement_id, label, url }]
|
||||
* visitedMap {object} – { [requirement_id]: boolean }
|
||||
* onVisit {function} – async (requirement_id) => void
|
||||
* called when user confirms "Turn In"
|
||||
*/
|
||||
const VisitLink = ({ title = "Visit Links", links = [], visitedMap = {}, onVisit }) => {
|
||||
const [submittingId, setSubmittingId] = useState(null);
|
||||
|
||||
const handleTurnIn = async (requirementId) => {
|
||||
setSubmittingId(requirementId);
|
||||
try {
|
||||
await onVisit?.(requirementId);
|
||||
} finally {
|
||||
setSubmittingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg bg-card overflow-hidden">
|
||||
<div className="px-6 py-4 border-b flex items-center gap-2">
|
||||
<ExternalLink className="size-4 text-muted-foreground" />
|
||||
<h2 className="font-semibold text-md">{title}</h2>
|
||||
<Badge className="ml-auto">{links.length}</Badge>
|
||||
</div>
|
||||
<ScrollArea className="w-full bg-muted overflow-hidden">
|
||||
<div className="flex gap-4 p-4">
|
||||
{links.map((link) => (
|
||||
<LinkCard
|
||||
key={link.requirement_id}
|
||||
link={link}
|
||||
visited={!!visitedMap[link.requirement_id]}
|
||||
onTurnIn={handleTurnIn}
|
||||
submitting={submittingId === link.requirement_id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VisitLink;
|
||||
Reference in New Issue
Block a user