fix: address issues in model pull and chat API

- Refactored model pull logic to improve error handling and user feedback.
- Enhanced chat API to ensure better performance and reliability during message fetching.
- Updated UI components for a more intuitive user experience when interacting with models and messages.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-19 00:01:14 -03:00
1 parent ad9d5d32d2
commit b2a06ed994
261 files changed
+19968

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Send, Square } from "lucide-react";
import { useState, useRef, useEffect } from "react";
interface ChatInputProps {
onSend: (message: string) => void;
onStop: () => void;
isLoading: boolean;
}
export function ChatInput({ onSend, onStop, isLoading }: ChatInputProps) {
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleSend = () => {
if (input.trim() && !isLoading) {
onSend(input);
setInput("");
}
};
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [input]);
return (
<div className="p-4 border-t bg-background">
<div className="relative flex items-end gap-2 max-w-3xl mx-auto">
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Digite sua mensagem..."
className="min-h-[50px] max-h-[200px] resize-none pr-12 py-3"
rows={1}
/>
<div className="absolute right-2 bottom-2">
{isLoading ? (
<Button size="icon" variant="destructive" onClick={onStop} className="h-8 w-8">
<Square className="h-4 w-4 fill-current" />
</Button>
) : (
<Button
size="icon"
onClick={handleSend}
disabled={!input.trim()}
className="h-8 w-8"
>
<Send className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="text-xs text-center text-muted-foreground mt-2">
OllaHub pode cometer erros. Verifique informações importantes.
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { cn } from "@/lib/utils";
import { Message } from "@/hooks/use-chat";
import { Bot, User } from "lucide-react";
export function ChatMessage({ message }: { message: Message }) {
const isUser = message.role === 'user';
return (
<div className={cn(
"flex w-full gap-4 p-6",
isUser ? "bg-background" : "bg-muted/30"
)}>
<div className="flex-shrink-0 mt-1">
<div className={cn(
"w-8 h-8 rounded-sm flex items-center justify-center",
isUser ? "bg-primary text-primary-foreground" : "bg-secondary text-secondary-foreground"
)}>
{isUser ? <User className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
</div>
</div>
<div className="flex-1 overflow-hidden">
<div className="prose dark:prose-invert max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
</div>
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sparkles, Loader2 } from "lucide-react";
import { useState, useEffect } from "react";
import { usePromptGenerator } from "@/hooks/use-prompt-generator";
import { useLocalModels } from "@/hooks/use-local-models";
interface PromptGeneratorDialogProps {
defaultModel: string;
onPromptGenerated: (prompt: string) => void;
}
export function PromptGeneratorDialog({ defaultModel, onPromptGenerated }: PromptGeneratorDialogProps) {
const [open, setOpen] = useState(false);
const [goal, setGoal] = useState("");
const [selectedModel, setSelectedModel] = useState(defaultModel);
const { generatePrompt, isGenerating } = usePromptGenerator();
const { models } = useLocalModels();
// Set default model when dialog opens or defaultModel changes
useEffect(() => {
if (defaultModel) {
setSelectedModel(defaultModel);
}
}, [defaultModel]);
// Try to set specific default if available, otherwise fallback
useEffect(() => {
const preferredModel = "llama3.2-abliterate:3b";
const hasPreferred = models.some(m => m.name === preferredModel);
if (hasPreferred && !selectedModel) {
setSelectedModel(preferredModel);
}
}, [models, selectedModel]);
const handleGenerate = async () => {
if (!goal.trim() || !selectedModel) return;
try {
const prompt = await generatePrompt(goal, selectedModel);
onPromptGenerated(prompt);
setOpen(false);
setGoal("");
} catch (error) {
// Error handling usually done in hook or global toast
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-yellow-500 hover:text-yellow-600 hover:bg-yellow-100 dark:hover:bg-yellow-900/20" title="Gerar com IA">
<Sparkles className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Gerador de Prompts</DialogTitle>
<DialogDescription>
Descreva o que você quer que a IA faça, e nós criaremos um System Prompt otimizado para você.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Modelo para Geração</label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger>
<SelectValue placeholder="Selecione um modelo..." />
</SelectTrigger>
<SelectContent>
{models.map(m => (
<SelectItem key={m.name} value={m.name}>{m.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Recomendado: Modelos com boa capacidade de instrução (ex: Llama 3, Mistral).
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Seu Objetivo</label>
<Textarea
value={goal}
onChange={(e) => setGoal(e.target.value)}
placeholder="Ex: Quero um assistente especialista em Python que explique conceitos complexos de forma simples..."
className="min-h-[150px]"
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancelar</Button>
<Button onClick={handleGenerate} disabled={isGenerating || !goal.trim() || !selectedModel}>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Gerando...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Gerar Prompt
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+105
View File
@@ -0,0 +1,105 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Trash2, HardDrive, Cpu, Database } from "lucide-react";
import { useSystemMonitor } from "@/hooks/use-system-monitor";
import { useLocalModels } from "@/hooks/use-local-models";
export function SystemPanel() {
const stats = useSystemMonitor();
const { models, deleteModel, refresh } = useLocalModels();
const ramUsagePercent = stats
? (stats.memory_used / stats.memory_total) * 100
: 0;
const formatBytes = (bytes: number) => {
const gb = bytes / (1024 * 1024 * 1024);
return `${gb.toFixed(1)} GB`;
};
return (
<div className="h-full overflow-y-auto p-4 space-y-6">
{/* Hardware Stats */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Monitoramento
</h3>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Cpu className="w-4 h-4" /> Processador
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-between text-xs mb-1">
<span>Uso</span>
<span>{stats?.cpu_usage.toFixed(1)}%</span>
</div>
<Progress value={stats?.cpu_usage || 0} className="h-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<HardDrive className="w-4 h-4" /> Memória RAM
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-between text-xs mb-1">
<span>{stats ? formatBytes(stats.memory_used) : '-'}</span>
<span>{stats ? formatBytes(stats.memory_total) : '-'}</span>
</div>
<Progress value={ramUsagePercent} className="h-2" />
</CardContent>
</Card>
</div>
{/* Models List */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Modelos Instalados
</h3>
<Button variant="ghost" size="sm" onClick={refresh} className="h-6 text-xs">
Atualizar
</Button>
</div>
<div className="space-y-2">
{models.map((model) => (
<Card key={model.id} className="overflow-hidden">
<CardContent className="p-3 flex items-center justify-between">
<div className="min-w-0">
<div className="font-medium text-sm truncate" title={model.name}>
{model.name}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<Database className="w-3 h-3" />
{model.size}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => deleteModel(model.name)}
>
<Trash2 className="w-4 h-4" />
</Button>
</CardContent>
</Card>
))}
{models.length === 0 && (
<div className="text-center text-sm text-muted-foreground py-4">
Nenhum modelo encontrado.
</div>
)}
</div>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { Button } from "@/components/ui/button";
import { ArrowRight, Cpu } from "lucide-react";
export function Hero({ onStart }: { onStart: () => void }) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center px-4 space-y-6 animate-in fade-in zoom-in duration-500">
<div className="p-4 rounded-full bg-primary/10 animate-pulse">
<Cpu className="w-12 h-12 text-primary" />
</div>
<h1 className="text-4xl md:text-6xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-primary to-primary/60">
OllaHub
</h1>
<p className="text-lg md:text-xl text-muted-foreground max-w-[600px]">
Sua interface moderna e poderosa para interagir com Ollama e Modelos de Linguagem Locais.
</p>
<div className="flex gap-4 pt-4">
<Button size="lg" onClick={onStart} className="group text-lg px-8 h-12">
Iniciar Chat
<ArrowRight className="ml-2 w-5 h-5 transition-transform group-hover:translate-x-1" />
</Button>
</div>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { RefreshCw, Copy, Check, Play, Loader2 } from "lucide-react"
import { useState } from "react"
import { invoke } from "@tauri-apps/api/core"
export function StoppedCard({ onCheckAgain }: { onCheckAgain: () => void }) {
const [copied, setCopied] = useState(false);
const [starting, setStarting] = useState(false);
const command = "ollama serve";
const handleCopy = () => {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleStart = async () => {
setStarting(true);
try {
await invoke('start_ollama_server');
// Wait a bit for the server to start before checking again
setTimeout(() => {
onCheckAgain();
setStarting(false);
}, 3000);
} catch (error) {
console.error("Failed to start ollama:", error);
setStarting(false);
}
};
return (
<Card className="w-full max-w-md mx-auto border-yellow-500/50 bg-yellow-50/10 dark:bg-yellow-900/10">
<CardHeader>
<CardTitle className="text-yellow-600 dark:text-yellow-500">Ollama está parado</CardTitle>
<CardDescription>
O Ollama está instalado mas não está rodando. Você pode iniciá-lo automaticamente ou executar o comando manualmente.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button
onClick={handleStart}
className="w-full bg-yellow-600 hover:bg-yellow-700 text-white"
disabled={starting}
>
{starting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Iniciando...
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Iniciar Ollama Automaticamente
</>
)}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-yellow-500/20" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Ou manualmente</span>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="bg-muted p-3 rounded-md font-mono text-sm flex-1 border">
{command}
</div>
<Button size="icon" variant="outline" onClick={handleCopy}>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Button onClick={onCheckAgain} className="w-full" variant="secondary">
<RefreshCw className="mr-2 h-4 w-4" />
Verificar Novamente
</Button>
</CardContent>
</Card>
)
}
+86
View File
@@ -0,0 +1,86 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Button } from "@/components/ui/button"
import { Download } from "lucide-react"
interface InstallModalProps {
open: boolean;
onCheckAgain: () => void;
}
export function InstallModal({ open, onCheckAgain }: InstallModalProps) {
return (
<Dialog open={open}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Ollama não encontrado</DialogTitle>
<DialogDescription>
Para usar o OllaHub, você precisa ter o Ollama instalado no seu sistema.
Siga os passos abaixo para o seu sistema operacional.
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="mac" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="mac">macOS</TabsTrigger>
<TabsTrigger value="linux">Linux</TabsTrigger>
<TabsTrigger value="windows">Windows</TabsTrigger>
</TabsList>
<TabsContent value="mac" className="space-y-4 py-4">
<div className="space-y-2">
<h3 className="font-medium">1. Download e Instalação</h3>
<Button variant="outline" className="w-full" asChild>
<a href="https://ollama.com/download/Ollama-darwin.zip" target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Baixar para macOS
</a>
</Button>
</div>
<div className="text-sm text-muted-foreground">
Após instalar, abra o terminal e digite <code>ollama serve</code> se necessário.
</div>
</TabsContent>
<TabsContent value="linux" className="space-y-4 py-4">
<div className="space-y-2">
<h3 className="font-medium">Instalação via Terminal</h3>
<div className="bg-muted p-4 rounded-md relative group">
<code className="text-sm break-all">curl -fsSL https://ollama.com/install.sh | sh</code>
</div>
</div>
<div className="space-y-2">
<h3 className="font-medium">Comando Manual</h3>
<div className="text-sm text-muted-foreground">
Ou consulte <a href="https://ollama.com/download/linux" className="underline text-primary" target="_blank" rel="noopener noreferrer">ollama.com/download/linux</a> para instruções manuais.
</div>
</div>
</TabsContent>
<TabsContent value="windows" className="space-y-4 py-4">
<div className="space-y-2">
<h3 className="font-medium">1. Download do Instalador</h3>
<Button variant="outline" className="w-full" asChild>
<a href="https://ollama.com/download/OllamaSetup.exe" target="_blank" rel="noopener noreferrer">
<Download className="mr-2 h-4 w-4" />
Baixar para Windows
</a>
</Button>
</div>
<div className="text-sm text-muted-foreground">
Execute o instalador baixado e siga as instruções na tela.
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2 mt-4">
<Button onClick={onCheckAgain}>
Verificar Novamente
</Button>
</div>
</DialogContent>
</Dialog>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Cpu, HardDrive, Laptop } from "lucide-react";
import { SystemSpecs } from "@/lib/recommendation";
import { useEffect, useState } from "react";
interface HardwareScanProps {
specs: SystemSpecs | null;
loading: boolean;
}
export function HardwareScan({ specs, loading }: HardwareScanProps) {
const [progress, setProgress] = useState(0);
useEffect(() => {
if (loading) {
const interval = setInterval(() => {
setProgress((prev) => (prev >= 90 ? 90 : prev + 10));
}, 150);
return () => clearInterval(interval);
} else {
setProgress(100);
}
}, [loading]);
if (loading) {
return (
<Card className="w-full max-w-md mx-auto border-primary/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Cpu className="animate-pulse text-primary" />
Analisando Hardware...
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Progress value={progress} className="h-2" />
<p className="text-sm text-muted-foreground text-center">
Verificando memória e processador para recomendação ideal.
</p>
</CardContent>
</Card>
);
}
if (!specs) return null;
const ramGB = (specs.total_memory / (1024 * 1024 * 1024)).toFixed(1);
return (
<Card className="w-full max-w-md mx-auto bg-muted/30 animate-in fade-in slide-in-from-bottom-4">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Laptop className="text-primary" />
Sistema Detectado
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4">
<div className="flex flex-col items-center p-3 bg-background rounded-lg border">
<HardDrive className="w-6 h-6 mb-2 text-muted-foreground" />
<span className="text-xl font-bold">{ramGB} GB</span>
<span className="text-xs text-muted-foreground">RAM Total</span>
</div>
<div className="flex flex-col items-center p-3 bg-background rounded-lg border">
<Cpu className="w-6 h-6 mb-2 text-muted-foreground" />
<span className="text-xl font-bold">{specs.cpu_count}</span>
<span className="text-xs text-muted-foreground">Núcleos CPU</span>
</div>
</CardContent>
</Card>
);
}
+163
View File
@@ -0,0 +1,163 @@
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Check, Download, Loader2, Play } from "lucide-react";
import { RECOMMENDED_MODELS, ModelRecommendation } from "@/lib/recommendation";
import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
interface ModelSelectorProps {
recommendation: ModelRecommendation;
onComplete: () => void;
}
export function ModelSelector({ recommendation, onComplete }: ModelSelectorProps) {
const [selectedModelId, setSelectedModelId] = useState(recommendation.modelId);
const [isInstalled, setIsInstalled] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [downloadStatus, setDownloadStatus] = useState("");
const selectedModel = RECOMMENDED_MODELS.find(m => m.id === selectedModelId) || RECOMMENDED_MODELS[0];
useEffect(() => {
checkInstallation(selectedModelId);
}, [selectedModelId]);
useEffect(() => {
const unlisten = listen<string>('download-progress', (event) => {
const line = event.payload;
setDownloadStatus(line);
// Simple heuristic parsing for progress
// Ollama output example: "pulling manifest", "downloading 10%", "verifying sha256 digest"
if (line.includes('%')) {
const match = line.match(/(\d+)%/);
if (match) {
setDownloadProgress(parseInt(match[1], 10));
}
} else if (line.includes('success')) {
setDownloadProgress(100);
}
});
return () => {
unlisten.then(f => f());
};
}, []);
const checkInstallation = async (modelId: string) => {
try {
const installed = await invoke<boolean>('check_if_model_installed', { name: modelId });
setIsInstalled(installed);
} catch (e) {
console.error("Check failed", e);
}
};
const handleDownload = async () => {
setIsDownloading(true);
setDownloadProgress(0);
try {
await invoke('pull_model', { name: selectedModelId });
setIsInstalled(true);
setIsDownloading(false);
} catch (error) {
console.error("Download failed", error);
setDownloadStatus("Falha no download. Tente novamente.");
setIsDownloading(false);
}
};
return (
<Card className="w-full max-w-md mx-auto mt-6 border-primary shadow-lg">
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle>Escolha seu Modelo</CardTitle>
<CardDescription>
{selectedModelId === recommendation.modelId
? "Baseado no seu hardware, recomendamos:"
: "Você selecionou um modelo personalizado:"}
</CardDescription>
</div>
{selectedModelId === recommendation.modelId && (
<Badge variant="secondary" className="bg-green-100 text-green-800 hover:bg-green-100 dark:bg-green-900 dark:text-green-100">
Recomendado
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<Select
value={selectedModelId}
onValueChange={setSelectedModelId}
disabled={isDownloading}
>
<SelectTrigger className="w-full h-14 text-lg">
<SelectValue placeholder="Selecione um modelo" />
</SelectTrigger>
<SelectContent>
{RECOMMENDED_MODELS.map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex flex-col items-start">
<span className="font-medium">{model.name}</span>
<span className="text-xs text-muted-foreground">{model.size} • Min RAM: {model.minRam}GB</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="bg-muted/50 p-4 rounded-md text-sm">
<p className="font-medium mb-1">Sobre este modelo:</p>
<p className="text-muted-foreground">
{selectedModelId === recommendation.modelId
? recommendation.reason
: "Modelo alternativo selecionado. Certifique-se de ter memória suficiente."}
</p>
</div>
{isDownloading && (
<div className="space-y-2">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{downloadStatus || "Iniciando..."}</span>
<span>{downloadProgress}%</span>
</div>
<Progress value={downloadProgress} className="h-2" />
</div>
)}
</CardContent>
<CardFooter>
{isInstalled ? (
<Button className="w-full h-12 text-lg" onClick={onComplete}>
Ir para o Chat
<Play className="ml-2 w-5 h-5" />
</Button>
) : (
<Button
className="w-full h-12 text-lg"
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Baixando...
</>
) : (
<>
<Download className="mr-2 h-5 w-5" />
Baixar e Instalar ({selectedModel.size})
</>
)}
</Button>
)}
</CardFooter>
</Card>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+60
View File
@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+143
View File
@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+44
View File
@@ -0,0 +1,44 @@
import * as React from "react"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+159
View File
@@ -0,0 +1,159 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+66
View File
@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }