DocDoeAI / src /components /dashboard /SourcesPanel.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
11.8 kB
"use client";
import { useMemo, useState } from "react";
import { Check, FileUp, MoreVertical, Plus, Search, UploadCloud, Wifi, WifiOff } from "lucide-react";
import LiquidGlass from "liquid-glass-react";
import { cn } from "@/lib/utils";
import { sourceFilters, type DashboardSource } from "@/components/dashboard/dashboardData";
import { PreviewBadge } from "@/components/dashboard/dashboardPrimitives";
import { MaterialTypeBadge } from "@/components/dashboard/SourceBlockedState";
import { getPublicDemoMode } from "@/lib/env";
import { hasLiveSession } from "@/lib/api/mode";
type SourcesPanelProps = {
className?: string;
selectedSourceId: string;
selectedSourceIds?: string[];
documents?: DashboardSource[];
onSourceOpen: (sourceId: string) => void;
onSourceToggle?: (sourceId: string) => void;
onUploadClick?: () => void;
};
export function SourcesPanel({
className,
selectedSourceId,
selectedSourceIds = [selectedSourceId],
documents = [],
onSourceOpen,
onSourceToggle,
onUploadClick,
}: SourcesPanelProps) {
const [activeFilter, setActiveFilter] = useState("All");
const [query, setQuery] = useState("");
const isLive = getPublicDemoMode() === "live" || hasLiveSession();
const liveBadge = isLive ? (
<span className="ml-2 inline-flex items-center gap-1 rounded-full border border-emerald-400/30 bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-emerald-300">
<Wifi className="size-3" /> Connected to AI
</span>
) : (
<span className="ml-2 inline-flex items-center gap-1 rounded-full border border-amber-400/30 bg-amber-500/10 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-amber-300">
<WifiOff className="size-3" /> Offline preview
</span>
);
const visibleSources = useMemo(
() =>
documents.filter((source) => {
const filterMatches =
activeFilter === "All" ||
source.type === activeFilter ||
(activeFilter === "Books" && source.type === "Book") ||
(activeFilter === "PYQs" && source.type === "PYQ") ||
(activeFilter === "Links" && source.type === "Link") ||
(activeFilter === "Images" && source.type === "Image");
const searchText = `${source.title} ${source.subtitle} ${source.type}`.toLowerCase();
return filterMatches && searchText.includes(query.trim().toLowerCase());
}),
[activeFilter, query, documents],
);
const pinnedSources = visibleSources.filter((source) => source.group === "Pinned");
const recentSources = visibleSources.filter((source) => source.group === "Recent");
const hasSources = visibleSources.length > 0;
const selectedCount = selectedSourceIds.length;
const sourceUsageLabel =
selectedCount > 1
? `Using ${selectedCount} sources`
: selectedCount === 1
? "Using 1 source"
: "Choose sources for DocDoe";
return (
<LiquidGlass
blurAmount={0.04}
saturation={120}
elasticity={0.16}
displacementScale={30}
aberrationIntensity={1.3}
cornerRadius={24}
mode="standard"
className={cn(
"flex min-h-0 w-full min-w-0 flex-col overflow-hidden rounded-[24px] border border-white/[0.08] bg-[#0b0614]/35 shadow-[0_22px_80px_rgba(0,0,0,0.32),inset_0_1px_0_rgba(255,255,255,0.05)] sm:min-h-[400px] lg:h-full lg:min-h-0",
className,
)}
>
<div className="flex items-center justify-between border-b border-white/[0.06] bg-white/[0.02] px-3 py-2.5 sm:px-4 sm:py-3">
<div>
<h2 className="text-[13px] font-bold text-white/90">Sources {liveBadge}</h2>
<p className="mt-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-white/30">{sourceUsageLabel}</p>
</div>
<button
type="button"
aria-label="Add new source"
onClick={onUploadClick}
className="group flex size-9 cursor-pointer items-center justify-center rounded-xl border border-violet-300/18 bg-violet-400/[0.08] text-violet-200 transition hover:border-violet-300/35 hover:bg-violet-400/[0.12] sm:size-7 sm:rounded-lg"
>
<Plus className="size-4 transition-transform group-hover:rotate-90" strokeWidth={2.5} />
</button>
</div>
<div className="space-y-2.5 p-3">
<label className="flex h-9 items-center gap-2.5 rounded-xl border border-white/5 bg-black/24 px-3 text-white/40 focus-within:border-violet-500/30 transition-all">
<Search className="size-3.5 shrink-0" strokeWidth={2} />
<input
className="min-w-0 flex-1 bg-transparent text-[12px] text-white outline-none placeholder:text-white/20"
onChange={(e) => setQuery(e.target.value)}
placeholder="Search documents..."
type="search"
value={query}
/>
</label>
<div className="flex gap-1 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{sourceFilters.filter(f => f !== "Images").map((tab) => (
<button
key={tab}
onClick={() => setActiveFilter(tab)}
className={cn(
"h-8 shrink-0 rounded-xl px-3 text-[11px] font-bold transition-all sm:h-7 sm:rounded-lg sm:px-2.5",
tab === activeFilter
? "bg-violet-500/20 text-violet-100 shadow-[0_0_15px_rgba(124,58,237,0.15)]"
: "text-white/40 hover:bg-white/5 hover:text-white/60",
)}
>
{tab}
</button>
))}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 pb-3 [scrollbar-color:rgba(255,255,255,0.1)_transparent] [scrollbar-width:thin]">
{hasSources ? (
<div className="space-y-4">
{pinnedSources.length > 0 && (
<SourceGroup title="Pinned" sources={pinnedSources} selectedSourceId={selectedSourceId} selectedSourceIds={selectedSourceIds} onSourceOpen={onSourceOpen} onSourceToggle={onSourceToggle} />
)}
<SourceGroup title="Recent" sources={recentSources} selectedSourceId={selectedSourceId} selectedSourceIds={selectedSourceIds} onSourceOpen={onSourceOpen} onSourceToggle={onSourceToggle} />
</div>
) : (
<div className="py-12 text-center">
<p className="text-[12px] font-medium text-white/35">No sources found</p>
</div>
)}
<button
onClick={onUploadClick}
type="button"
className="group mt-5 flex w-full items-center gap-3 rounded-2xl border border-dashed border-white/5 bg-white/[0.02] p-3 transition hover:border-violet-500/20 hover:bg-violet-500/[0.04] sm:mt-6"
>
<div className="grid size-9 place-items-center rounded-xl bg-white/5 text-white/30 group-hover:bg-violet-500/10 group-hover:text-violet-400 transition-colors">
<UploadCloud className="size-4.5" />
</div>
<div className="min-w-0 text-left">
<p className="text-[12px] font-bold text-white/60 group-hover:text-white transition-colors">Add new source</p>
<p className="text-[10px] text-white/20 font-medium truncate">PDF, Notes, PYQs</p>
</div>
</button>
</div>
</LiquidGlass>
);
}
function SourceGroup({
title,
sources,
selectedSourceId,
selectedSourceIds,
onSourceOpen,
onSourceToggle,
}: {
title: string;
sources: DashboardSource[];
selectedSourceId: string;
selectedSourceIds: string[];
onSourceOpen: (sourceId: string) => void;
onSourceToggle?: (sourceId: string) => void;
}) {
if (sources.length === 0) {
return null;
}
return (
<div>
<p className="mb-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-white/42">{title}</p>
<div className="grid gap-2">
{sources.map((source) => (
<SourcePanelRow
key={source.id}
source={source}
isSelected={source.id === selectedSourceId}
isIncluded={selectedSourceIds.includes(source.id)}
onOpen={() => onSourceOpen(source.id)}
onToggle={() => onSourceToggle?.(source.id)}
/>
))}
</div>
</div>
);
}
function SourcePanelRow({
source,
isSelected,
isIncluded,
onOpen,
onToggle,
}: {
source: DashboardSource;
isSelected: boolean;
isIncluded: boolean;
onOpen: () => void;
onToggle: () => void;
}) {
const Icon = source.icon;
const statusLabel =
source.status === "processing" || source.status === "uploaded"
? "Preparing"
: source.status === "failed"
? "Could not read"
: "Ready";
return (
<div
className={cn(
"group flex w-full cursor-pointer items-center gap-3 rounded-[18px] border p-2.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-300/45",
isSelected
? "border-violet-300/28 bg-violet-500/[0.1] shadow-[0_0_26px_rgba(124,58,237,0.16),inset_0_1px_0_rgba(255,255,255,0.04)]"
: "border-white/[0.06] bg-white/[0.03] hover:border-violet-300/18 hover:bg-white/[0.052]",
)}
>
<button
type="button"
aria-pressed={isSelected}
onClick={onOpen}
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none"
>
<span className={cn(
"grid size-10 shrink-0 place-items-center rounded-2xl bg-gradient-to-br",
source.materialType === "resume_or_personal_doc"
? "from-white/[0.08] to-white/[0.02]"
: source.tone,
)}>
<Icon className="size-[18px] text-white/82" strokeWidth={1.85} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[12.5px] font-semibold text-white">{source.title}</span>
<span className="mt-0.5 flex flex-wrap items-center gap-1 truncate text-[11px] text-white/48">
{source.subject || "Study"}
{source.materialType && source.materialType !== "unknown" && (
<MaterialTypeBadge materialType={source.materialType} />
)}
{source.materialType === "unknown" && (
<span className="text-[9px] font-semibold uppercase tracking-wider text-white/25">
• Classify
</span>
)}
<span className="text-white/28 sm:hidden">- {statusLabel}</span>
</span>
</span>
</button>
<span className="flex shrink-0 items-center gap-1.5">
{isSelected ? <PreviewBadge>On</PreviewBadge> : null}
<button
type="button"
aria-pressed={isIncluded}
aria-label={isIncluded ? `Remove ${source.title} from DocDoe sources` : `Use ${source.title} with DocDoe`}
onClick={onToggle}
className={cn(
"grid size-6 place-items-center rounded-lg border transition",
isIncluded
? "border-violet-300/30 bg-violet-500/20 text-violet-100"
: "border-white/8 bg-white/[0.035] text-white/25 hover:text-white/60",
)}
>
{isIncluded ? <Check className="size-3.5" strokeWidth={2.5} /> : <span className="size-2 rounded-full border border-current" />}
</button>
<span className="hidden rounded-full border border-white/5 bg-white/[0.03] px-1.5 py-0.5 text-[9px] font-bold uppercase text-white/35 sm:inline-flex">
{statusLabel}
</span>
<FileUp className="hidden size-3.5 text-violet-200/55 group-hover:block" strokeWidth={1.8} aria-hidden="true" />
<MoreVertical className="size-4 text-white/38" strokeWidth={1.8} aria-hidden="true" />
</span>
</div>
);
}