refactor: format and clean up imports, hooks, and UI components for consistent structure and readability across models and datasets sections

This commit is contained in:
shine1i 2026-02-02 12:51:04 +01:00
commit af3e8c20ee
16 changed files with 670 additions and 216 deletions

1
.gitignore vendored
View file

@ -25,6 +25,7 @@ models/
# IDE / Editors
.vscode/
.idea/
.claude/
*.swp
*.swo

View file

@ -98,8 +98,21 @@ export const MODELS: ModelOption[] = [
hfRepo: "Qwen/Qwen-VL-Chat",
},
// TTS models
{ id: "bark", name: "Bark", type: "tts", params: "1B", hfRepo: "suno/bark", recommended: true },
{ id: "xtts-v2", name: "XTTS v2", type: "tts", params: "500M", hfRepo: "coqui/XTTS-v2" },
{
id: "bark",
name: "Bark",
type: "tts",
params: "1B",
hfRepo: "suno/bark",
recommended: true,
},
{
id: "xtts-v2",
name: "XTTS v2",
type: "tts",
params: "500M",
hfRepo: "coqui/XTTS-v2",
},
// Embedding models
{
id: "bge-large",
@ -111,24 +124,6 @@ export const MODELS: ModelOption[] = [
{ id: "e5-large", name: "E5 Large", type: "embeddings", params: "335M" },
{ id: "gte-large", name: "GTE Large", type: "embeddings", params: "335M" },
// Text models
{
id: "llama-3.1-8b",
name: "Llama 3.1 8B",
type: "text",
params: "8B",
vram: "~6GB",
context: "128K",
hfRepo: "unsloth/Llama-3.1-8B",
recommended: true,
},
{
id: "llama-3.1-70b",
name: "Llama 3.1 70B",
type: "text",
params: "70B",
vram: "~40GB",
context: "128K",
},
{
id: "mistral-7b",
name: "Mistral 7B",
@ -154,24 +149,6 @@ export const MODELS: ModelOption[] = [
vram: "~3GB",
context: "128K",
},
{
id: "gemma-2-9b",
name: "Gemma 2 9B",
type: "text",
params: "9B",
vram: "~7GB",
context: "8K",
},
{
id: "gemma-3-27b",
name: "Gemma 3 27B",
type: "text",
params: "27B",
vram: "~18GB",
context: "128K",
hfRepo: "unsloth/gemma-3-27b",
recommended: true,
},
];
export const DATASETS: DatasetOption[] = [
@ -259,7 +236,9 @@ export const DEFAULT_HYPERPARAMS = {
};
export function findModelById(id: string | null): ModelOption | undefined {
if (!id) return undefined;
if (!id) {
return undefined;
}
return MODELS.find((m) => m.id === id || m.hfRepo === id);
}

View file

@ -68,7 +68,9 @@ export function ExportDialog({
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Export Model</DialogTitle>
<DialogDescription>Choose where to save your exported model.</DialogDescription>
<DialogDescription>
Choose where to save your exported model.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
@ -94,18 +96,32 @@ export function ExportDialog({
<div className="flex flex-col gap-4 px-0.5">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">Username / Org</label>
<Input placeholder="your-username" value={hfUsername} onChange={(e) => onHfUsernameChange(e.target.value)} />
<label className="text-xs font-medium text-muted-foreground">
Username / Org
</label>
<Input
placeholder="your-username"
value={hfUsername}
onChange={(e) => onHfUsernameChange(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-muted-foreground">Model Name</label>
<Input placeholder="my-model-gguf" value={modelName} onChange={(e) => onModelNameChange(e.target.value)} />
<label className="text-xs font-medium text-muted-foreground">
Model Name
</label>
<Input
placeholder="my-model-gguf"
value={modelName}
onChange={(e) => onModelNameChange(e.target.value)}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-muted-foreground">HF Write Token</label>
<label className="text-xs font-medium text-muted-foreground">
HF Write Token
</label>
<a
href="https://huggingface.co/settings/tokens"
target="_blank"
@ -113,21 +129,41 @@ export function ExportDialog({
className="flex items-center gap-1 text-[11px] text-emerald-600 hover:text-emerald-700 transition-colors"
>
Get token
<HugeiconsIcon icon={ArrowRight01Icon} className="size-3" />
<HugeiconsIcon
icon={ArrowRight01Icon}
className="size-3"
/>
</a>
</div>
<InputGroup>
<InputGroupAddon>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
</InputGroupAddon>
<InputGroupInput type="password" placeholder="hf_..." value={hfToken} onChange={(e) => onHfTokenChange(e.target.value)} />
<InputGroupInput
type="password"
placeholder="hf_..."
value={hfToken}
onChange={(e) => onHfTokenChange(e.target.value)}
/>
</InputGroup>
<p className="text-[11px] text-muted-foreground/70">Leave empty if already logged in via CLI.</p>
<p className="text-[11px] text-muted-foreground/70">
Leave empty if already logged in via CLI.
</p>
</div>
<div className="flex items-center gap-3">
<Switch id="private-repo" size="sm" checked={privateRepo} onCheckedChange={onPrivateRepoChange} />
<label htmlFor="private-repo" className="text-xs font-medium cursor-pointer">Private Repository</label>
<Switch
id="private-repo"
size="sm"
checked={privateRepo}
onCheckedChange={onPrivateRepoChange}
/>
<label
htmlFor="private-repo"
className="text-xs font-medium cursor-pointer"
>
Private Repository
</label>
</div>
</div>
</motion.div>
@ -153,7 +189,9 @@ export function ExportDialog({
{exportMethod === "gguf" && quantLevels.length > 0 && (
<div className="flex justify-between">
<span>Quantizations</span>
<span className="font-medium text-foreground">{quantLevels.join(", ")}</span>
<span className="font-medium text-foreground">
{quantLevels.join(", ")}
</span>
</div>
)}
<div className="flex justify-between">
@ -163,7 +201,9 @@ export function ExportDialog({
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={() => onOpenChange(false)}>Start Export</Button>
</DialogFooter>
</DialogContent>

View file

@ -5,7 +5,10 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { CheckmarkCircle01Icon, InformationCircleIcon } from "@hugeicons/core-free-icons";
import {
CheckmarkCircle01Icon,
InformationCircleIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { EXPORT_METHODS, type ExportMethod } from "../constants";
@ -20,14 +23,24 @@ export function MethodPicker({ value, onChange }: MethodPickerProps) {
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Export Method
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="text-foreground/70 hover:text-foreground">
<TooltipTrigger asChild={true}>
<button
type="button"
className="text-foreground/70 hover:text-foreground"
>
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>
How your model is packaged for deployment.{" "}
<a href="https://unsloth.ai/docs/basics/inference-and-deployment" target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
<a
href="https://unsloth.ai/docs/basics/inference-and-deployment"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Read more
</a>
</TooltipContent>
</Tooltip>
</span>
@ -49,34 +62,61 @@ export function MethodPicker({ value, onChange }: MethodPickerProps) {
<div
className={cn(
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
selected ? "border-primary bg-primary" : "border-muted-foreground/30",
selected
? "border-primary bg-primary"
: "border-muted-foreground/30",
)}
>
{selected && (
<HugeiconsIcon icon={CheckmarkCircle01Icon} className="size-3 text-primary-foreground" />
<HugeiconsIcon
icon={CheckmarkCircle01Icon}
className="size-3 text-primary-foreground"
/>
)}
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{m.title}</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="shrink-0 text-foreground/50 hover:text-foreground cursor-help" onClick={(e) => e.stopPropagation()}>
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
<TooltipTrigger asChild={true}>
<span
className="shrink-0 text-foreground/50 hover:text-foreground cursor-help"
onClick={(e) => e.stopPropagation()}
>
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3"
/>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{m.tooltip}{" "}
<a href={m.value === "gguf" ? "https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf" : "https://unsloth.ai/docs/basics/inference-and-deployment"} target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
<a
href={
m.value === "gguf"
? "https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf"
: "https://unsloth.ai/docs/basics/inference-and-deployment"
}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Read more
</a>
</TooltipContent>
</Tooltip>
{m.badge && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0"
>
{m.badge}
</Badge>
)}
</div>
<span className="text-xs text-muted-foreground">{m.description}</span>
<span className="text-xs text-muted-foreground">
{m.description}
</span>
</div>
</button>
);

View file

@ -4,7 +4,11 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { CheckmarkCircle01Icon, InformationCircleIcon, LayersIcon } from "@hugeicons/core-free-icons";
import {
CheckmarkCircle01Icon,
InformationCircleIcon,
LayersIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { QUANT_OPTIONS } from "../constants";
@ -23,20 +27,38 @@ export function QuantPicker({ value, onChange }: QuantPickerProps) {
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<HugeiconsIcon icon={LayersIcon} className="size-4 text-muted-foreground" />
<span className="text-xs font-medium text-muted-foreground">Quantization Levels</span>
<HugeiconsIcon
icon={LayersIcon}
className="size-4 text-muted-foreground"
/>
<span className="text-xs font-medium text-muted-foreground">
Quantization Levels
</span>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="text-foreground/70 hover:text-foreground">
<TooltipTrigger asChild={true}>
<button
type="button"
className="text-foreground/70 hover:text-foreground"
>
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Lower quantization (Q2, Q3) = smaller files but reduced quality. Q4Q5 is a good balance.{" "}
<a href="https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf" target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
Lower quantization (Q2, Q3) = smaller files but reduced quality.
Q4Q5 is a good balance.{" "}
<a
href="https://unsloth.ai/docs/basics/inference-and-deployment/saving-to-gguf"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Read more
</a>
</TooltipContent>
</Tooltip>
<span className="text-[11px] text-muted-foreground/70"> select one or more</span>
<span className="text-[11px] text-muted-foreground/70">
select one or more
</span>
</div>
<div className="flex flex-wrap gap-2 py-1 pl-1">
{QUANT_OPTIONS.map((q) => {
@ -53,7 +75,12 @@ export function QuantPicker({ value, onChange }: QuantPickerProps) {
: "ring-border text-muted-foreground hover:text-foreground hover:ring-foreground/20",
)}
>
{active && <HugeiconsIcon icon={CheckmarkCircle01Icon} className="size-3" />}
{active && (
<HugeiconsIcon
icon={CheckmarkCircle01Icon}
className="size-3"
/>
)}
{q.label}
<span className="text-[10px] opacity-60">{q.size}</span>
{q.recommended && !active && (

View file

@ -9,9 +9,27 @@ export const EXPORT_METHODS: {
tooltip: string;
badge?: string;
}[] = [
{ value: "merged", title: "Merged Model", description: "Full 16-bit model ready for inference.", tooltip: "Merges adapter weights into the base model. Best for direct deployment with vLLM or TGI." },
{ value: "lora", title: "LoRA Only", description: "Lightweight adapter files (~100 MB). Needs base model.", tooltip: "Exports only the trained adapter. Pair with the base model at inference time to save storage." },
{ value: "gguf", title: "GGUF / Llama.cpp", description: "Quantized formats for local AI runners.", tooltip: "Converts to GGUF for llama.cpp, Ollama, and other local runners. Pick a quantization level below." },
{
value: "merged",
title: "Merged Model",
description: "Full 16-bit model ready for inference.",
tooltip:
"Merges adapter weights into the base model. Best for direct deployment with vLLM or TGI.",
},
{
value: "lora",
title: "LoRA Only",
description: "Lightweight adapter files (~100 MB). Needs base model.",
tooltip:
"Exports only the trained adapter. Pair with the base model at inference time to save storage.",
},
{
value: "gguf",
title: "GGUF / Llama.cpp",
description: "Quantized formats for local AI runners.",
tooltip:
"Converts to GGUF for llama.cpp, Ollama, and other local runners. Pick a quantization level below.",
},
];
export const QUANT_OPTIONS = [
@ -27,17 +45,27 @@ export const QUANT_OPTIONS = [
{ value: "f16", label: "F16", size: "~14.2 GB" },
];
export function getEstimatedSize(method: ExportMethod | null, quantLevels: string[]) {
const sizeOf = (v: string) => QUANT_OPTIONS.find((q) => q.value === v)?.size ?? "—";
export function getEstimatedSize(
method: ExportMethod | null,
quantLevels: string[],
) {
const sizeOf = (v: string) =>
QUANT_OPTIONS.find((q) => q.value === v)?.size ?? "—";
if (method === "gguf" && quantLevels.length > 0) {
if (quantLevels.length === 1) return sizeOf(quantLevels[0]);
if (quantLevels.length === 1) {
return sizeOf(quantLevels[0]);
}
const total = quantLevels
.map((q) => Number.parseFloat(sizeOf(q).replace(/[^0-9.]/g, "")))
.reduce((a, b) => a + b, 0);
return `~${total.toFixed(1)} GB (${quantLevels.length} files)`;
}
if (method === "merged") return "~14.2 GB";
if (method === "lora") return "~100 MB";
if (method === "merged") {
return "~14.2 GB";
}
if (method === "lora") {
return "~100 MB";
}
return "—";
}

View file

@ -1,3 +1,4 @@
import { SectionCard } from "@/components/section-card";
import { Button } from "@/components/ui/button";
import {
Select,
@ -7,15 +8,14 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { SectionCard } from "@/components/section-card";
import { findModelById } from "@/config/training";
import { useWizardStore } from "@/stores/training";
import { isAdapterMethod } from "@/types/training";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { findModelById } from "@/config/training";
import { useWizardStore } from "@/stores/training";
import { isAdapterMethod } from "@/types/training";
import { InformationCircleIcon, PackageIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { AnimatePresence, motion } from "motion/react";
@ -33,21 +33,32 @@ import {
} from "./constants";
export function ExportPage() {
const { trainingMethod, selectedModel, saveSteps, trainingMetrics, epochs, loraRank, hfToken, setHfToken } =
useWizardStore(
useShallow((s) => ({
trainingMethod: s.trainingMethod,
selectedModel: s.selectedModel,
saveSteps: s.saveSteps,
trainingMetrics: s.trainingMetrics,
epochs: s.epochs,
loraRank: s.loraRank,
hfToken: s.hfToken,
setHfToken: s.setHfToken,
})),
);
const {
trainingMethod,
selectedModel,
saveSteps,
trainingMetrics,
epochs,
loraRank,
hfToken,
setHfToken,
} = useWizardStore(
useShallow((s) => ({
trainingMethod: s.trainingMethod,
selectedModel: s.selectedModel,
saveSteps: s.saveSteps,
trainingMetrics: s.trainingMetrics,
epochs: s.epochs,
loraRank: s.loraRank,
hfToken: s.hfToken,
setHfToken: s.setHfToken,
})),
);
const isAdapter = isAdapterMethod(trainingMethod);
const modelInfo = useMemo(() => findModelById(selectedModel), [selectedModel]);
const modelInfo = useMemo(
() => findModelById(selectedModel),
[selectedModel],
);
const checkpoints = useMemo(() => {
if (isAdapter) {
@ -55,7 +66,11 @@ export function ExportPage() {
const total = trainingMetrics?.totalSteps ?? 500;
const entries: { value: string; label: string; detail: string }[] = [];
for (let step = interval; step <= total; step += interval) {
const loss = (1.5 - (step / total) * 0.7 + Math.random() * 0.05).toFixed(2);
const loss = (
1.5 -
(step / total) * 0.7 +
Math.random() * 0.05
).toFixed(2);
entries.push({
value: `checkpoint-${step}`,
label: `checkpoint-${step}`,
@ -64,7 +79,13 @@ export function ExportPage() {
}
return entries.reverse();
}
return [{ value: "final-model", label: "Final Model", detail: "Full fine-tuned weights" }];
return [
{
value: "final-model",
label: "Final Model",
detail: "Full fine-tuned weights",
},
];
}, [isAdapter, saveSteps, trainingMetrics?.totalSteps]);
const [checkpoint, setCheckpoint] = useState<string | null>(null);
@ -79,19 +100,28 @@ export function ExportPage() {
const handleMethodChange = (method: ExportMethod) => {
setExportMethod(method);
if (method !== "gguf") setQuantLevels([]);
if (method !== "gguf") {
setQuantLevels([]);
}
};
const estimatedSize = getEstimatedSize(exportMethod, quantLevels);
const canExport = checkpoint && exportMethod && (exportMethod !== "gguf" || quantLevels.length > 0);
const canExport =
checkpoint &&
exportMethod &&
(exportMethod !== "gguf" || quantLevels.length > 0);
const baseModelName = modelInfo?.name ?? selectedModel ?? "—";
return (
<div className="min-h-screen bg-background">
<main className="mx-auto max-w-7xl px-6 py-8">
<div className="mb-8 flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Export Model</h1>
<p className="text-sm text-muted-foreground">Export your fine-tuned model for deployment</p>
<h1 className="text-2xl font-semibold tracking-tight">
Export Model
</h1>
<p className="text-sm text-muted-foreground">
Export your fine-tuned model for deployment
</p>
</div>
<SectionCard
@ -99,7 +129,7 @@ export function ExportPage() {
title="Export Configuration"
description="Select checkpoint, method, and quantization"
accent="emerald"
featured
featured={true}
className="shadow-border ring-1 ring-border"
>
{/* Top row: Checkpoint + metadata | Guide */}
@ -109,27 +139,47 @@ export function ExportPage() {
<label className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
{isAdapter ? "Checkpoint" : "Model"}
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="text-foreground/70 hover:text-foreground">
<HugeiconsIcon icon={InformationCircleIcon} className="size-3" />
<TooltipTrigger asChild={true}>
<button
type="button"
className="text-foreground/70 hover:text-foreground"
>
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3"
/>
</button>
</TooltipTrigger>
<TooltipContent>
Choose a saved checkpoint to export. Lower loss generally means better quality.{" "}
<a href="https://unsloth.ai/docs/basics/inference-and-deployment" target="_blank" rel="noopener noreferrer" className="text-primary underline">Read more</a>
Choose a saved checkpoint to export. Lower loss generally
means better quality.{" "}
<a
href="https://unsloth.ai/docs/basics/inference-and-deployment"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
Read more
</a>
</TooltipContent>
</Tooltip>
</label>
<Select value={checkpoint ?? ""} onValueChange={setCheckpoint}>
<SelectTrigger className="w-full">
<SelectValue placeholder={isAdapter ? "Select a checkpoint…" : "Select model…"} />
<SelectValue
placeholder={
isAdapter ? "Select a checkpoint…" : "Select model…"
}
/>
</SelectTrigger>
<SelectContent>
{checkpoints.map((cp) => (
<SelectItem key={cp.value} value={cp.value}>
<span className="flex items-center gap-2">
{cp.label}
<span className="text-muted-foreground text-xs">{cp.detail}</span>
<span className="text-muted-foreground text-xs">
{cp.detail}
</span>
</span>
</SelectItem>
))}
@ -138,7 +188,9 @@ export function ExportPage() {
</div>
<div className="rounded-xl bg-muted/50 p-3 flex flex-col gap-2">
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">Training Info</span>
<span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
Training Info
</span>
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Base Model</span>
@ -146,7 +198,9 @@ export function ExportPage() {
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Method</span>
<span className="font-medium">{METHOD_LABELS[trainingMethod] ?? trainingMethod}</span>
<span className="font-medium">
{METHOD_LABELS[trainingMethod] ?? trainingMethod}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Checkpoints</span>
@ -173,10 +227,15 @@ export function ExportPage() {
</div>
<div className="flex flex-col gap-2.5">
<span className="text-xs font-medium text-muted-foreground">Quick Guide</span>
<span className="text-xs font-medium text-muted-foreground">
Quick Guide
</span>
<ol className="flex flex-col gap-3">
{GUIDE_STEPS.map((step, i) => (
<li key={step} className="flex items-start gap-2 text-xs text-muted-foreground">
<li
key={step}
className="flex items-start gap-2 text-xs text-muted-foreground"
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-semibold">
{i + 1}
</span>
@ -200,7 +259,10 @@ export function ExportPage() {
<Separator />
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<HugeiconsIcon icon={InformationCircleIcon} className="size-3.5" />
<HugeiconsIcon
icon={InformationCircleIcon}
className="size-3.5"
/>
<span>Est. size: {estimatedSize} · Free disk space: 120 GB</span>
</div>
<Button disabled={!canExport} onClick={() => setDialogOpen(true)}>

View file

@ -33,7 +33,11 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DATASETS } from "@/config/training";
import { useDebouncedValue, useHfDatasetSearch, useInfiniteScroll } from "@/hooks";
import {
useDebouncedValue,
useHfDatasetSearch,
useInfiniteScroll,
} from "@/hooks";
import { cn, formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import type { DatasetFormat } from "@/types/training";
@ -83,23 +87,53 @@ export function DatasetStep() {
const [inputValue, setInputValue] = useState("");
const debouncedQuery = useDebouncedValue(inputValue);
const { results: hfResults, isLoading, isLoadingMore, hasMore, fetchMore } = useHfDatasetSearch(debouncedQuery, {
const {
results: hfResults,
isLoading,
isLoadingMore,
hasMore,
fetchMore,
} = useHfDatasetSearch(debouncedQuery, {
accessToken: hfToken || undefined,
});
const curatedDatasets = useMemo(
() => [...DATASETS].sort((a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0)),
() =>
[...DATASETS].sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
),
[],
);
const datasetMap = useMemo(() => {
const map = new Map<string, { label: string; description?: string; size?: string; totalExamples?: number; sizeCategory?: string; downloads?: number; recommended?: boolean }>();
const map = new Map<
string,
{
label: string;
description?: string;
size?: string;
totalExamples?: number;
sizeCategory?: string;
downloads?: number;
recommended?: boolean;
}
>();
for (const d of curatedDatasets) {
map.set(d.id, { label: d.name, description: d.description, size: d.size, recommended: d.recommended });
map.set(d.id, {
label: d.name,
description: d.description,
size: d.size,
recommended: d.recommended,
});
}
for (const r of hfResults) {
if (!map.has(r.id)) {
map.set(r.id, { label: r.id, downloads: r.downloads, totalExamples: r.totalExamples, sizeCategory: r.sizeCategory });
map.set(r.id, {
label: r.id,
downloads: r.downloads,
totalExamples: r.totalExamples,
sizeCategory: r.sizeCategory,
});
}
}
return map;
@ -111,14 +145,24 @@ export function DatasetStep() {
}
const q = debouncedQuery.toLowerCase();
const curatedIds = curatedDatasets
.filter((d) => d.name.toLowerCase().includes(q) || d.id.toLowerCase().includes(q))
.filter(
(d) =>
d.name.toLowerCase().includes(q) || d.id.toLowerCase().includes(q),
)
.map((d) => d.id);
const liveIds = hfResults.map((r) => r.id).filter((id) => !curatedIds.includes(id));
const liveIds = hfResults
.map((r) => r.id)
.filter((id) => !curatedIds.includes(id));
return [...curatedIds, ...liveIds];
}, [debouncedQuery, curatedDatasets, hfResults]);
const allIds = useMemo(
() => [...new Set([...curatedDatasets.map((d) => d.id), ...hfResults.map((r) => r.id)])],
() => [
...new Set([
...curatedDatasets.map((d) => d.id),
...hfResults.map((r) => r.id),
]),
],
[curatedDatasets, hfResults],
);
@ -204,35 +248,56 @@ export function DatasetStep() {
itemToStringValue={(id) => datasetMap.get(id)?.label ?? id}
autoHighlight={true}
>
<ComboboxInput placeholder="Search datasets..." className="w-full">
<ComboboxInput
placeholder="Search datasets..."
className="w-full"
>
<InputGroupAddon>
<HugeiconsIcon icon={Search01Icon} className="size-4" />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent anchor={comboboxAnchorRef}>
{isLoading ? (
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground"><Spinner className="size-4" /> Searching</div>
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
<Spinner className="size-4" /> Searching
</div>
) : (
<ComboboxEmpty>No datasets found</ComboboxEmpty>
)}
<div ref={scrollRef} className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]"
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const meta = datasetMap.get(id);
const label = meta?.label ?? id;
const rowLabel = meta?.size ?? (meta?.totalExamples ? `${formatCompact(meta.totalExamples)} rows` : null);
const rowLabel =
meta?.size ??
(meta?.totalExamples
? `${formatCompact(meta.totalExamples)} rows`
: null);
return (
<ComboboxItem key={id} value={id} className="justify-between">
<ComboboxItem
key={id}
value={id}
className="justify-between"
>
<Tooltip>
<TooltipTrigger asChild>
<TooltipTrigger asChild={true}>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="truncate">{label}</span>
{meta?.description && (
<span className="text-xs text-muted-foreground truncate">{meta.description}</span>
<span className="text-xs text-muted-foreground truncate">
{meta.description}
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs break-all">
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{label}
</TooltipContent>
</Tooltip>

View file

@ -31,8 +31,12 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MODEL_TYPE_TO_HF_TASK, MODELS } from "@/config/training";
import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
import { MODELS, MODEL_TYPE_TO_HF_TASK } from "@/config/training";
import {
useDebouncedValue,
useHfModelSearch,
useInfiniteScroll,
} from "@/hooks";
import { formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import type { TrainingMethod } from "@/types/training";
@ -69,26 +73,51 @@ export function ModelSelectionStep() {
const [inputValue, setInputValue] = useState("");
const debouncedQuery = useDebouncedValue(inputValue);
const task = modelType ? MODEL_TYPE_TO_HF_TASK[modelType] : undefined;
const { results: hfResults, isLoading, isLoadingMore, hasMore, fetchMore } = useHfModelSearch(debouncedQuery, {
const {
results: hfResults,
isLoading,
isLoadingMore,
hasMore,
fetchMore,
} = useHfModelSearch(debouncedQuery, {
task,
accessToken: hfToken || undefined,
});
const curatedModels = useMemo(() => {
if (!modelType) return [];
if (!modelType) {
return [];
}
return MODELS.filter((m) => m.type === modelType).sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
);
}, [modelType]);
const modelMap = useMemo(() => {
const map = new Map<string, { label: string; params?: string; totalParams?: number; downloads?: number; recommended?: boolean }>();
const map = new Map<
string,
{
label: string;
params?: string;
totalParams?: number;
downloads?: number;
recommended?: boolean;
}
>();
for (const m of curatedModels) {
map.set(m.hfRepo ?? m.id, { label: m.name, params: m.params, recommended: m.recommended });
map.set(m.hfRepo ?? m.id, {
label: m.name,
params: m.params,
recommended: m.recommended,
});
}
for (const r of hfResults) {
if (!map.has(r.id)) {
map.set(r.id, { label: r.id, downloads: r.downloads, totalParams: r.totalParams });
map.set(r.id, {
label: r.id,
downloads: r.downloads,
totalParams: r.totalParams,
});
}
}
return map;
@ -100,15 +129,32 @@ export function ModelSelectionStep() {
}
const q = debouncedQuery.toLowerCase();
const curatedIds = curatedModels
.filter((m) => m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q) || m.hfRepo?.toLowerCase().includes(q))
.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.id.toLowerCase().includes(q) ||
m.hfRepo?.toLowerCase().includes(q),
)
.map((m) => m.hfRepo ?? m.id);
const liveIds = hfResults.map((r) => r.id).filter((id) => !curatedIds.includes(id));
const liveIds = hfResults
.map((r) => r.id)
.filter((id) => !curatedIds.includes(id));
return [...curatedIds, ...liveIds];
}, [debouncedQuery, curatedModels, hfResults]);
const allIds = useMemo(() => [...new Set([...curatedModels.map((m) => m.hfRepo ?? m.id), ...hfResults.map((r) => r.id)])], [curatedModels, hfResults]);
const allIds = useMemo(
() => [
...new Set([
...curatedModels.map((m) => m.hfRepo ?? m.id),
...hfResults.map((r) => r.id),
]),
],
[curatedModels, hfResults],
);
const selectedModelData = MODELS.find((m) => m.id === selectedModel || m.hfRepo === selectedModel);
const selectedModelData = MODELS.find(
(m) => m.id === selectedModel || m.hfRepo === selectedModel,
);
const comboboxAnchorRef = useRef<HTMLDivElement>(null);
const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore);
@ -189,36 +235,59 @@ export function ModelSelectionStep() {
</ComboboxInput>
<ComboboxContent anchor={comboboxAnchorRef}>
{isLoading ? (
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground"><Spinner className="size-4" /> Searching</div>
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
<Spinner className="size-4" /> Searching
</div>
) : (
<ComboboxEmpty>No models found</ComboboxEmpty>
)}
<div ref={scrollRef} className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]"
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const meta = modelMap.get(id);
const label = meta?.label ?? id;
const sizeLabel = meta?.params ?? (meta?.totalParams ? formatCompact(meta.totalParams) : null);
const sizeLabel =
meta?.params ??
(meta?.totalParams
? formatCompact(meta.totalParams)
: null);
return (
<ComboboxItem key={id} value={id} className="justify-between">
<ComboboxItem
key={id}
value={id}
className="justify-between"
>
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 truncate">{label}</span>
<TooltipTrigger asChild={true}>
<span className="min-w-0 flex-1 truncate">
{label}
</span>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs break-all">
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{label}
</TooltipContent>
</Tooltip>
<span className="flex items-center gap-1.5 shrink-0">
{meta?.recommended && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-emerald-600 border-emerald-200 dark:border-emerald-800 dark:text-emerald-400">
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 text-emerald-600 border-emerald-200 dark:border-emerald-800 dark:text-emerald-400"
>
Recommended
</Badge>
)}
{sizeLabel ? (
<Badge variant="outline">{sizeLabel}</Badge>
) : meta?.downloads != null ? (
<span className="text-[10px] text-muted-foreground">{formatCompact(meta.downloads)}</span>
<span className="text-[10px] text-muted-foreground">
{formatCompact(meta.downloads)}
</span>
) : null}
</span>
</ComboboxItem>
@ -271,7 +340,8 @@ export function ModelSelectionStep() {
</Tooltip>
</FieldLabel>
<FieldDescription>
Choose how to fine-tune {selectedModelData?.name ?? selectedModel}
Choose how to fine-tune{" "}
{selectedModelData?.name ?? selectedModel}
</FieldDescription>
</div>
<Select

View file

@ -2,8 +2,8 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { DATASETS, findModelById } from "@/config/training";
import { isAdapterMethod } from "@/types/training";
import { useWizardStore } from "@/stores/training";
import { isAdapterMethod } from "@/types/training";
import { GpuIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useShallow } from "zustand/react/shallow";

View file

@ -13,10 +13,7 @@ import {
ComboboxItem,
ComboboxList,
} from "@/components/ui/combobox";
import {
InputGroupAddon,
} from "@/components/ui/input-group";
import { Spinner } from "@/components/ui/spinner";
import { InputGroupAddon } from "@/components/ui/input-group";
import {
Select,
SelectContent,
@ -24,13 +21,18 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DATASETS } from "@/config/training";
import { useDebouncedValue, useHfDatasetSearch, useInfiniteScroll } from "@/hooks";
import {
useDebouncedValue,
useHfDatasetSearch,
useInfiniteScroll,
} from "@/hooks";
import { formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import {
@ -61,23 +63,51 @@ export function DatasetSection() {
const [inputValue, setInputValue] = useState("");
const debouncedQuery = useDebouncedValue(inputValue);
const { results: hfResults, isLoading, isLoadingMore, hasMore, fetchMore } = useHfDatasetSearch(debouncedQuery, {
const {
results: hfResults,
isLoading,
isLoadingMore,
hasMore,
fetchMore,
} = useHfDatasetSearch(debouncedQuery, {
accessToken: hfToken || undefined,
});
const curatedDatasets = useMemo(
() => [...DATASETS].sort((a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0)),
() =>
[...DATASETS].sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
),
[],
);
const datasetMap = useMemo(() => {
const map = new Map<string, { label: string; description?: string; size?: string; totalExamples?: number; sizeCategory?: string; downloads?: number }>();
const map = new Map<
string,
{
label: string;
description?: string;
size?: string;
totalExamples?: number;
sizeCategory?: string;
downloads?: number;
}
>();
for (const d of curatedDatasets) {
map.set(d.id, { label: d.name, description: d.description, size: d.size });
map.set(d.id, {
label: d.name,
description: d.description,
size: d.size,
});
}
for (const r of hfResults) {
if (!map.has(r.id)) {
map.set(r.id, { label: r.id, downloads: r.downloads, totalExamples: r.totalExamples, sizeCategory: r.sizeCategory });
map.set(r.id, {
label: r.id,
downloads: r.downloads,
totalExamples: r.totalExamples,
sizeCategory: r.sizeCategory,
});
}
}
return map;
@ -89,14 +119,24 @@ export function DatasetSection() {
}
const q = debouncedQuery.toLowerCase();
const curatedIds = curatedDatasets
.filter((d) => d.name.toLowerCase().includes(q) || d.id.toLowerCase().includes(q))
.filter(
(d) =>
d.name.toLowerCase().includes(q) || d.id.toLowerCase().includes(q),
)
.map((d) => d.id);
const liveIds = hfResults.map((r) => r.id).filter((id) => !curatedIds.includes(id));
const liveIds = hfResults
.map((r) => r.id)
.filter((id) => !curatedIds.includes(id));
return [...curatedIds, ...liveIds];
}, [debouncedQuery, curatedDatasets, hfResults]);
const allIds = useMemo(
() => [...new Set([...curatedDatasets.map((d) => d.id), ...hfResults.map((r) => r.id)])],
() => [
...new Set([
...curatedDatasets.map((d) => d.id),
...hfResults.map((r) => r.id),
]),
],
[curatedDatasets, hfResults],
);
@ -129,7 +169,8 @@ export function DatasetSection() {
</button>
</TooltipTrigger>
<TooltipContent>
Search Hugging Face datasets or enter a path like 'username/dataset-name'.{" "}
Search Hugging Face datasets or enter a path like
'username/dataset-name'.{" "}
<a
href="https://unsloth.ai/docs/get-started/fine-tuning-llms-guide/datasets-guide"
target="_blank"
@ -152,30 +193,51 @@ export function DatasetSection() {
itemToStringValue={(id) => datasetMap.get(id)?.label ?? id}
autoHighlight={true}
>
<ComboboxInput placeholder="Search datasets..." className="w-full">
<ComboboxInput
placeholder="Search datasets..."
className="w-full"
>
<InputGroupAddon>
<HugeiconsIcon icon={Search01Icon} className="size-4" />
</InputGroupAddon>
</ComboboxInput>
<ComboboxContent anchor={comboboxAnchorRef}>
{isLoading ? (
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground"><Spinner className="size-4" /> Searching</div>
<div className="flex items-center justify-center py-4 gap-2 text-xs text-muted-foreground">
<Spinner className="size-4" /> Searching
</div>
) : (
<ComboboxEmpty>No datasets found</ComboboxEmpty>
)}
<div ref={scrollRef} className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]"
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const meta = datasetMap.get(id);
const label = meta?.label ?? id;
const rowLabel = meta?.size ?? (meta?.totalExamples ? `${formatCompact(meta.totalExamples)} rows` : null);
const rowLabel =
meta?.size ??
(meta?.totalExamples
? `${formatCompact(meta.totalExamples)} rows`
: null);
return (
<ComboboxItem key={id} value={id} className="justify-between">
<ComboboxItem
key={id}
value={id}
className="justify-between"
>
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 truncate">{label}</span>
<TooltipTrigger asChild={true}>
<span className="min-w-0 flex-1 truncate">
{label}
</span>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs break-all">
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{label}
</TooltipContent>
</Tooltip>

View file

@ -25,8 +25,12 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { MODEL_TYPE_TO_HF_TASK, MODELS } from "@/config/training";
import { useDebouncedValue, useHfModelSearch, useInfiniteScroll } from "@/hooks";
import { MODELS, MODEL_TYPE_TO_HF_TASK } from "@/config/training";
import {
useDebouncedValue,
useHfModelSearch,
useInfiniteScroll,
} from "@/hooks";
import { formatCompact } from "@/lib/utils";
import { useWizardStore } from "@/stores/training";
import type { TrainingMethod } from "@/types/training";
@ -76,26 +80,51 @@ export function ModelSection() {
const [inputValue, setInputValue] = useState("");
const debouncedQuery = useDebouncedValue(inputValue);
const task = modelType ? MODEL_TYPE_TO_HF_TASK[modelType] : undefined;
const { results: hfResults, isLoading, isLoadingMore, hasMore, fetchMore } = useHfModelSearch(debouncedQuery, {
const {
results: hfResults,
isLoading,
isLoadingMore,
hasMore,
fetchMore,
} = useHfModelSearch(debouncedQuery, {
task,
accessToken: hfToken || undefined,
});
const curatedModels = useMemo(() => {
if (!modelType) return MODELS;
if (!modelType) {
return MODELS;
}
return MODELS.filter((m) => m.type === modelType).sort(
(a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0),
);
}, [modelType]);
const modelMap = useMemo(() => {
const map = new Map<string, { label: string; params?: string; totalParams?: number; downloads?: number; recommended?: boolean }>();
const map = new Map<
string,
{
label: string;
params?: string;
totalParams?: number;
downloads?: number;
recommended?: boolean;
}
>();
for (const m of curatedModels) {
map.set(m.hfRepo ?? m.id, { label: m.name, params: m.params, recommended: m.recommended });
map.set(m.hfRepo ?? m.id, {
label: m.name,
params: m.params,
recommended: m.recommended,
});
}
for (const r of hfResults) {
if (!map.has(r.id)) {
map.set(r.id, { label: r.id, downloads: r.downloads, totalParams: r.totalParams });
map.set(r.id, {
label: r.id,
downloads: r.downloads,
totalParams: r.totalParams,
});
}
}
return map;
@ -107,14 +136,26 @@ export function ModelSection() {
}
const q = debouncedQuery.toLowerCase();
const curatedIds = curatedModels
.filter((m) => m.name.toLowerCase().includes(q) || m.id.toLowerCase().includes(q) || m.hfRepo?.toLowerCase().includes(q))
.filter(
(m) =>
m.name.toLowerCase().includes(q) ||
m.id.toLowerCase().includes(q) ||
m.hfRepo?.toLowerCase().includes(q),
)
.map((m) => m.hfRepo ?? m.id);
const liveIds = hfResults.map((r) => r.id).filter((id) => !curatedIds.includes(id));
const liveIds = hfResults
.map((r) => r.id)
.filter((id) => !curatedIds.includes(id));
return [...curatedIds, ...liveIds];
}, [debouncedQuery, curatedModels, hfResults]);
const allIds = useMemo(
() => [...new Set([...curatedModels.map((m) => m.hfRepo ?? m.id), ...hfResults.map((r) => r.id)])],
() => [
...new Set([
...curatedModels.map((m) => m.hfRepo ?? m.id),
...hfResults.map((r) => r.id),
]),
],
[curatedModels, hfResults],
);
@ -161,7 +202,10 @@ export function ModelSection() {
placeholder="./models/my-model"
value={
selectedModel
? (MODELS.find((m) => m.id === selectedModel || m.hfRepo === selectedModel)?.hfRepo ?? selectedModel)
? (MODELS.find(
(m) =>
m.id === selectedModel || m.hfRepo === selectedModel,
)?.hfRepo ?? selectedModel)
: ""
}
onChange={(e) => setSelectedModel(e.target.value || null)}
@ -222,19 +266,35 @@ export function ModelSection() {
) : (
<ComboboxEmpty>No models found</ComboboxEmpty>
)}
<div ref={scrollRef} className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<div
ref={scrollRef}
className="max-h-64 overflow-y-auto overscroll-contain [scrollbar-width:thin]"
>
<ComboboxList className="p-1 !max-h-none !overflow-visible">
{(id: string) => {
const meta = modelMap.get(id);
const label = meta?.label ?? id;
const sizeLabel = meta?.params ?? (meta?.totalParams ? formatCompact(meta.totalParams) : null);
const sizeLabel =
meta?.params ??
(meta?.totalParams
? formatCompact(meta.totalParams)
: null);
return (
<ComboboxItem key={id} value={id} className="justify-between">
<ComboboxItem
key={id}
value={id}
className="justify-between"
>
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 truncate">{label}</span>
<TooltipTrigger asChild={true}>
<span className="min-w-0 flex-1 truncate">
{label}
</span>
</TooltipTrigger>
<TooltipContent side="left" className="max-w-xs break-all">
<TooltipContent
side="left"
className="max-w-xs break-all"
>
{label}
</TooltipContent>
</Tooltip>

View file

@ -23,7 +23,9 @@ interface CardDataWithInfo {
function extractTotalExamples(
cardData: CardDataWithInfo | undefined,
): number | undefined {
if (!cardData?.dataset_info) return undefined;
if (!cardData?.dataset_info) {
return undefined;
}
const infos = Array.isArray(cardData.dataset_info)
? cardData.dataset_info
: [cardData.dataset_info];

View file

@ -25,7 +25,9 @@ async function pullBatch<T>(
const items: T[] = [];
for (let i = 0; i < size; i++) {
const result = await iter.next();
if (result.done) return { items, done: true };
if (result.done) {
return { items, done: true };
}
items.push(mapItem(result.value));
}
return { items, done: false };
@ -67,7 +69,9 @@ export function useHfPaginatedSearch<T>(
pullBatch(iter, mapItem, BATCH)
.then(({ items, done }) => {
if (versionRef.current !== v) return;
if (versionRef.current !== v) {
return;
}
setState({
results: items,
isLoading: false,
@ -77,7 +81,9 @@ export function useHfPaginatedSearch<T>(
});
})
.catch((err) => {
if (versionRef.current !== v) return;
if (versionRef.current !== v) {
return;
}
setState({
results: [],
isLoading: false,
@ -91,14 +97,18 @@ export function useHfPaginatedSearch<T>(
const fetchMore = useCallback(() => {
const iter = iterRef.current;
const { isLoading, isLoadingMore, hasMore } = stateRef.current;
if (!iter || isLoading || isLoadingMore || !hasMore) return;
if (!iter || isLoading || isLoadingMore || !hasMore) {
return;
}
const v = versionRef.current;
setState((prev) => ({ ...prev, isLoadingMore: true }));
pullBatch(iter, mapItem, BATCH)
.then(({ items, done }) => {
if (versionRef.current !== v) return;
if (versionRef.current !== v) {
return;
}
setState((prev) => ({
...prev,
results: [...prev.results, ...items],
@ -107,7 +117,9 @@ export function useHfPaginatedSearch<T>(
}));
})
.catch(() => {
if (versionRef.current !== v) return;
if (versionRef.current !== v) {
return;
}
setState((prev) => ({ ...prev, isLoadingMore: false, hasMore: false }));
});
}, [mapItem]);

View file

@ -6,10 +6,14 @@ export function useInfiniteScroll(fetchMore: () => void) {
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
if (!el) {
return;
}
const obs = new IntersectionObserver(
([e]) => {
if (e.isIntersecting) fetchMore();
if (e.isIntersecting) {
fetchMore();
}
},
{ threshold: 0, root: scrollRef.current },
);

View file

@ -1,19 +1,21 @@
import * as React from "react"
import * as React from "react";
const MOBILE_BREAKPOINT = 768
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile
return !!isMobile;
}