mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
course,tasklist,task and completed validation
Signed-off-by: rgrgogu <obsequio.rus@gmail.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { Music2, RotateCcw, RotateCw, Play, Pause, VolumeOff, Volume2 } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -16,11 +17,6 @@ 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(/\/$/, "");
|
||||
|
||||
// How often onWatchProgress may fire while playing (ms) — keeps the watch-progress
|
||||
// endpoint from getting hit on every timeupdate tick. onEnded still always reports
|
||||
// a final 100% immediately regardless of this window, so completion never lags.
|
||||
const WATCH_PROGRESS_THROTTLE_MS = 10000;
|
||||
|
||||
// ─── AudioBlock (Client — secure) ────────────────────────────────────────────
|
||||
//
|
||||
// S3/Garage:
|
||||
@@ -39,9 +35,10 @@ const WATCH_PROGRESS_THROTTLE_MS = 10000;
|
||||
|
||||
// onWatchProgress(percent) — optional, called (throttled) as playback advances and
|
||||
// immediately at 100% on end. Backing the watch_percent completion requirement type.
|
||||
export function AudioBlock({ content, onWatchProgress }) {
|
||||
export function AudioBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
|
||||
const audioRef = useRef(null);
|
||||
const lastReportRef = useRef(0);
|
||||
const guard = useMediaWatchGuard({ onWatchProgress, antiSkipEnabled });
|
||||
const resumedRef = useRef(false);
|
||||
|
||||
// ── Stream state ──────────────────────────────────────────────────────────
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
@@ -76,6 +73,8 @@ export function AudioBlock({ content, onWatchProgress }) {
|
||||
setPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
guard.reset();
|
||||
resumedRef.current = false;
|
||||
|
||||
// Legacy direct URL — no asset_id
|
||||
if (!assetId && directUrl) {
|
||||
@@ -127,17 +126,40 @@ export function AudioBlock({ content, onWatchProgress }) {
|
||||
const onTimeUpdate = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
setCurrentTime(el?.currentTime ?? 0);
|
||||
if (onWatchProgress && el?.duration) {
|
||||
guard.trackTimeUpdate(el?.currentTime ?? 0, el?.duration);
|
||||
if (el?.duration) {
|
||||
const pct = (el.currentTime / el.duration) * 100;
|
||||
const now = Date.now();
|
||||
if (now - lastReportRef.current > WATCH_PROGRESS_THROTTLE_MS) {
|
||||
lastReportRef.current = now;
|
||||
onWatchProgress(pct);
|
||||
}
|
||||
guard.maybeReport(pct);
|
||||
}
|
||||
}, [onWatchProgress]);
|
||||
const onLoadedMeta = useCallback(() => setDuration(audioRef.current?.duration ?? 0), []);
|
||||
}, [guard]);
|
||||
const onLoadedMeta = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
const d = el?.duration ?? 0;
|
||||
setDuration(d);
|
||||
guard.setDuration(d);
|
||||
// Resume from the last position reached, once per asset. Left alone once
|
||||
// fully watched (>=99%) — restarting reads better than resuming at the end.
|
||||
if (el && !resumedRef.current && resumePercent > 0 && resumePercent < 99 && d) {
|
||||
resumedRef.current = true;
|
||||
const resumeSeconds = Math.min((resumePercent / 100) * d, d - 0.25);
|
||||
el.currentTime = resumeSeconds;
|
||||
setCurrentTime(resumeSeconds);
|
||||
// Seeds the seek-cap so forward-seeking within already-watched territory
|
||||
// works immediately, instead of clamping back to 0 until the next tick.
|
||||
guard.trackTimeUpdate(resumeSeconds, d);
|
||||
}
|
||||
}, [guard, resumePercent]);
|
||||
const onEnded = useCallback(() => { setPlaying(false); onWatchProgress?.(100); }, [onWatchProgress]);
|
||||
const onPlay = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (el?.duration) guard.reportPlayStart((el.currentTime / el.duration) * 100);
|
||||
}, [guard]);
|
||||
const onPause = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (el?.duration) guard.flush((el.currentTime / el.duration) * 100);
|
||||
}, [guard]);
|
||||
const onSeeking = useCallback(() => guard.markSeeking(), [guard]);
|
||||
const onSeeked = useCallback(() => guard.markSeeked(), [guard]);
|
||||
const onProgress = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (el?.buffered.length && el.duration) {
|
||||
@@ -158,13 +180,13 @@ export function AudioBlock({ content, onWatchProgress }) {
|
||||
const el = audioRef.current;
|
||||
const bar = e.currentTarget;
|
||||
const pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth;
|
||||
el.currentTime = pct * duration;
|
||||
el.currentTime = guard.clampSeekTarget(pct * duration);
|
||||
};
|
||||
|
||||
const skip = (secs) => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
el.currentTime = Math.min(Math.max(0, el.currentTime + secs), duration);
|
||||
el.currentTime = guard.clampSeekTarget(Math.min(Math.max(0, el.currentTime + secs), duration));
|
||||
};
|
||||
|
||||
const handleVolume = (e) => {
|
||||
@@ -215,6 +237,10 @@ export function AudioBlock({ content, onWatchProgress }) {
|
||||
onTimeUpdate={onTimeUpdate}
|
||||
onLoadedMetadata={onLoadedMeta}
|
||||
onEnded={onEnded}
|
||||
onPlay={onPlay}
|
||||
onPause={onPause}
|
||||
onSeeking={onSeeking}
|
||||
onSeeked={onSeeked}
|
||||
onProgress={onProgress}
|
||||
preload="auto"
|
||||
/>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { VideoBlock } from "./VideoBlock";
|
||||
|
||||
export function TextVideoBlock({ content, onWatchProgress }) {
|
||||
export function TextVideoBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
|
||||
const vidLeft = content.video_position === "left";
|
||||
return (
|
||||
<div className="flex flex-col gap-6 items-start sm:grid sm:grid-cols-2 sm:gap-6">
|
||||
{vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} />}
|
||||
{vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />}
|
||||
<div
|
||||
className="typeset text-sm w-full"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: content.body || "<p class='text-muted-foreground italic'>Empty text</p>",
|
||||
}}
|
||||
/>
|
||||
{!vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} />}
|
||||
{!vidLeft && <VideoBlock content={content} onWatchProgress={onWatchProgress} resumePercent={resumePercent} antiSkipEnabled={antiSkipEnabled} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import api from "@/utils/api.util";
|
||||
import { MediaFallback } from "@/components/generic/MediaFallback";
|
||||
import { useMediaWatchGuard } from "@/hooks/useMediaWatchGuard";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,11 +24,6 @@ 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(/\/$/, "");
|
||||
|
||||
// How often onWatchProgress may fire while playing (ms) — keeps the watch-progress
|
||||
// endpoint from getting hit on every timeupdate tick. onEnded still always reports
|
||||
// a final 100% immediately regardless of this window, so completion never lags.
|
||||
const WATCH_PROGRESS_THROTTLE_MS = 10000;
|
||||
|
||||
// ─── Tooltip control button ───────────────────────────────────────────────────
|
||||
|
||||
function CtrlBtn({ label, onClick, children, className = "" }) {
|
||||
@@ -167,10 +163,11 @@ function SettingsPanel({ speed, onSpeed, onClose }) {
|
||||
// onWatchProgress(percent) — optional, called (throttled) as playback advances and
|
||||
// immediately at 100% on end. Backing the watch_percent completion requirement type;
|
||||
// harmless/unused when the lesson isn't configured for it (caller just won't pass it).
|
||||
export function VideoBlock({ content, onWatchProgress }) {
|
||||
export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEnabled }) {
|
||||
const wrapRef = useRef(null);
|
||||
const vidRef = useRef(null);
|
||||
const lastReportRef = useRef(0);
|
||||
const guard = useMediaWatchGuard({ onWatchProgress, antiSkipEnabled });
|
||||
const resumedRef = useRef(false);
|
||||
|
||||
// ── Stream state ──────────────────────────────────────────────────────────
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
@@ -229,6 +226,8 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
setOverlayVisible(true);
|
||||
setSettingsOpen(false);
|
||||
setEnded(false);
|
||||
guard.reset();
|
||||
resumedRef.current = false;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -275,23 +274,41 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
setCurrentTime(v.currentTime);
|
||||
guard.trackTimeUpdate(v.currentTime, v.duration);
|
||||
if (v.duration) {
|
||||
const pct = (v.currentTime / v.duration) * 100;
|
||||
setProgress(pct);
|
||||
if (onWatchProgress) {
|
||||
const now = Date.now();
|
||||
if (now - lastReportRef.current > WATCH_PROGRESS_THROTTLE_MS) {
|
||||
lastReportRef.current = now;
|
||||
onWatchProgress(pct);
|
||||
}
|
||||
}
|
||||
guard.maybeReport(pct);
|
||||
}
|
||||
};
|
||||
const onLoaded = () => {
|
||||
setTotalDuration(v.duration);
|
||||
guard.setDuration(v.duration);
|
||||
// Resume from the last position reached, once per asset. Left alone once
|
||||
// fully watched (>=99%) — restarting reads better than resuming at the end.
|
||||
if (!resumedRef.current && resumePercent > 0 && resumePercent < 99 && v.duration) {
|
||||
resumedRef.current = true;
|
||||
const resumeSeconds = Math.min((resumePercent / 100) * v.duration, v.duration - 0.25);
|
||||
v.currentTime = resumeSeconds;
|
||||
setCurrentTime(resumeSeconds);
|
||||
setProgress(resumePercent);
|
||||
// Seeds the seek-cap so forward-seeking within already-watched territory
|
||||
// works immediately, instead of clamping back to 0 until the next tick.
|
||||
guard.trackTimeUpdate(resumeSeconds, v.duration);
|
||||
}
|
||||
};
|
||||
const onLoaded = () => setTotalDuration(v.duration);
|
||||
const onEnded = () => {
|
||||
setPlaying(false); setOverlayVisible(false); setEnded(true);
|
||||
onWatchProgress?.(100);
|
||||
};
|
||||
const onPlay = () => {
|
||||
if (v.duration) guard.reportPlayStart((v.currentTime / v.duration) * 100);
|
||||
};
|
||||
const onPause = () => {
|
||||
if (v.duration) guard.flush((v.currentTime / v.duration) * 100);
|
||||
};
|
||||
const onSeeking = () => guard.markSeeking();
|
||||
const onSeeked = () => guard.markSeeked();
|
||||
const onWaiting = () => setBuffering(true);
|
||||
const onCanPlay = () => setBuffering(false);
|
||||
const onProgress = () => {
|
||||
@@ -305,9 +322,13 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
v.addEventListener("timeupdate", onTimeUpdate);
|
||||
v.addEventListener("loadedmetadata", onLoaded);
|
||||
v.addEventListener("ended", onEnded);
|
||||
v.addEventListener("play", onPlay);
|
||||
v.addEventListener("pause", onPause);
|
||||
v.addEventListener("seeking", onSeeking);
|
||||
v.addEventListener("seeked", onSeeked);
|
||||
v.addEventListener("progress", onProgress);
|
||||
|
||||
if (v.readyState >= 1 && v.duration) setTotalDuration(v.duration);
|
||||
if (v.readyState >= 1 && v.duration) onLoaded();
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("waiting", onWaiting);
|
||||
@@ -315,6 +336,10 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
v.removeEventListener("timeupdate", onTimeUpdate);
|
||||
v.removeEventListener("loadedmetadata", onLoaded);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("seeking", onSeeking);
|
||||
v.removeEventListener("seeked", onSeeked);
|
||||
v.removeEventListener("progress", onProgress);
|
||||
};
|
||||
}, [blobUrl]);
|
||||
@@ -372,7 +397,7 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
const handleSeek = (e) => {
|
||||
const v = vidRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = (parseFloat(e.target.value) / 100) * v.duration;
|
||||
v.currentTime = guard.clampSeekTarget((parseFloat(e.target.value) / 100) * v.duration);
|
||||
};
|
||||
|
||||
const handleVolumeChange = (e) => {
|
||||
@@ -407,7 +432,7 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
break;
|
||||
case "ArrowRight":
|
||||
e.preventDefault();
|
||||
if (vidRef.current) vidRef.current.currentTime = Math.min(vidRef.current.currentTime + 5, vidRef.current.duration);
|
||||
if (vidRef.current) vidRef.current.currentTime = guard.clampSeekTarget(Math.min(vidRef.current.currentTime + 5, vidRef.current.duration));
|
||||
showFeedback(<SkipForward className="size-7 text-white" />, "+5s");
|
||||
resetHideTimer();
|
||||
break;
|
||||
@@ -445,7 +470,7 @@ export function VideoBlock({ content, onWatchProgress }) {
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback]);
|
||||
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback, guard]);
|
||||
|
||||
// ── States ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* File Name : TaskMultiSelect.jsx
|
||||
* Type : Reusable Component
|
||||
* Description : Searchable multi-select dropdown for sibling Tasks (used to pick
|
||||
* a task's prerequisite tasks). Cloned from GroupMultiSelect.jsx's
|
||||
* UI/UX — portal dropdown, badge + "+N" overflow, select all/clear —
|
||||
* but takes `tasks` directly from the caller instead of fetching its
|
||||
* own endpoint, since the parent page already has the sibling task
|
||||
* list loaded.
|
||||
*
|
||||
* Props:
|
||||
* value : string[] — selected task_ids
|
||||
* onChange : (ids: string[]) => void
|
||||
* tasks : { task_id, name }[] — candidate tasks (already excludes self)
|
||||
* disabled? : boolean
|
||||
* placeholder?: string
|
||||
***********************************************************************************************************************************************************************/
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronsUpDown, X, ListChecks } from 'lucide-react';
|
||||
|
||||
export default function TaskMultiSelect({
|
||||
value = [],
|
||||
onChange,
|
||||
tasks = [],
|
||||
disabled = false,
|
||||
placeholder = 'Select tasks…',
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [dropdownStyle, setDropdownStyle] = useState({});
|
||||
|
||||
const triggerRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// ── Position portal dropdown under trigger ────────────────────────────────
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !triggerRef.current) return;
|
||||
|
||||
const reposition = () => {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setDropdownStyle({
|
||||
position: 'fixed',
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
zIndex: 9999,
|
||||
});
|
||||
};
|
||||
|
||||
reposition();
|
||||
window.addEventListener('scroll', reposition, true);
|
||||
window.addEventListener('resize', reposition);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
window.removeEventListener('resize', reposition);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// ── Close on outside click ────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e) => {
|
||||
if (
|
||||
triggerRef.current?.contains(e.target) ||
|
||||
dropdownRef.current?.contains(e.target)
|
||||
) return;
|
||||
setOpen(false);
|
||||
setSearch('');
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
const filtered = tasks.filter((t) =>
|
||||
t.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const selectedTasks = tasks.filter((t) => value.includes(t.task_id));
|
||||
const overflowCount = selectedTasks.length - 1;
|
||||
|
||||
// All currently visible (filtered) IDs — used for select-all scope
|
||||
const filteredIds = filtered.map((t) => t.task_id);
|
||||
const allFilteredSelected = filteredIds.length > 0 && filteredIds.every((id) => value.includes(id));
|
||||
|
||||
const toggle = (taskId) =>
|
||||
onChange(value.includes(taskId)
|
||||
? value.filter((id) => id !== taskId)
|
||||
: [...value, taskId]
|
||||
);
|
||||
|
||||
const remove = (e, taskId) => {
|
||||
e.stopPropagation();
|
||||
onChange(value.filter((id) => id !== taskId));
|
||||
};
|
||||
|
||||
// Select all visible (filtered) tasks
|
||||
const handleSelectAll = () => {
|
||||
const merged = Array.from(new Set([...value, ...filteredIds]));
|
||||
onChange(merged);
|
||||
};
|
||||
|
||||
// Clear all selections
|
||||
const handleClear = () => onChange([]);
|
||||
|
||||
// ── Portal dropdown ───────────────────────────────────────────────────────
|
||||
const dropdown = open && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
style={dropdownStyle}
|
||||
className="rounded-md border border-border bg-popover shadow-lg"
|
||||
>
|
||||
{/* Search row */}
|
||||
<div className="p-2 border-b border-border">
|
||||
<Input
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search tasks…"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Select all / Clear row */}
|
||||
{tasks.length > 0 && (
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-muted/40">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={allFilteredSelected ? handleClear : handleSelectAll}
|
||||
className="text-xs text-primary hover:underline underline-offset-2 font-medium"
|
||||
>
|
||||
{allFilteredSelected ? 'Deselect all' : 'Select all'}
|
||||
{search && ` (${filteredIds.length})`}
|
||||
</button>
|
||||
{value.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={handleClear}
|
||||
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
Clear ({value.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<ul className="max-h-52 overflow-y-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<li className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||
No tasks found.
|
||||
</li>
|
||||
) : (
|
||||
filtered.map((t) => {
|
||||
const selected = value.includes(t.task_id);
|
||||
return (
|
||||
<li
|
||||
key={t.task_id}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => toggle(t.task_id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 text-sm cursor-pointer select-none',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
selected && 'bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
'h-4 w-4 rounded border flex items-center justify-center shrink-0',
|
||||
selected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'border-input'
|
||||
)}>
|
||||
{selected && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
<ListChecks className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="truncate">{t.name}</span>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Trigger button ───────────────────────────────────────────── */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => { setOpen((o) => !o); setSearch(''); }}
|
||||
className={cn(
|
||||
'w-full min-h-9 px-3 py-1.5 rounded-md border border-input bg-background text-sm',
|
||||
'flex items-center gap-1.5 text-left',
|
||||
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
open && 'ring-2 ring-ring ring-offset-1'
|
||||
)}
|
||||
>
|
||||
{selectedTasks.length === 0 ? (
|
||||
<span className="text-muted-foreground flex-1">{placeholder}</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{/* Always show only the first selected task */}
|
||||
<Badge variant="secondary" className="gap-1 text-xs pr-1 shrink-0 max-w-[180px]">
|
||||
<ListChecks className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{selectedTasks[0].name}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => remove(e, selectedTasks[0].task_id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && remove(e, selectedTasks[0].task_id)}
|
||||
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5 cursor-pointer shrink-0"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
{/* +N overflow chip */}
|
||||
{overflowCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 shrink-0">
|
||||
+{overflowCount}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="h-3.5 w-3.5 text-muted-foreground shrink-0 ml-auto" />
|
||||
</button>
|
||||
|
||||
{/* ── Portalled dropdown ────────────────────────────────────────── */}
|
||||
{dropdown}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user