Initial open-source release
This commit is contained in:
95
src/components/AppList.tsx
Normal file
95
src/components/AppList.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useLoadApps } from "@/hooks/useLoadApps";
|
||||
|
||||
export function AppList({ show }: { show?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
|
||||
const { apps, loading, error } = useLoadApps();
|
||||
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAppClick = (id: number) => {
|
||||
setSelectedAppId(id);
|
||||
setSelectedChatId(null);
|
||||
navigate({
|
||||
to: "/",
|
||||
search: { appId: id },
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewApp = () => {
|
||||
navigate({ to: "/" });
|
||||
// We'll eventually need a create app workflow
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]">
|
||||
<SidebarGroupLabel>Your Apps</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Button
|
||||
onClick={handleNewApp}
|
||||
variant="outline"
|
||||
className="flex items-center justify-start gap-2 mx-2 py-2"
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
<span>New App</span>
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-2 px-4 text-sm text-gray-500">
|
||||
Loading apps...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="py-2 px-4 text-sm text-red-500">
|
||||
Error loading apps
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className="py-2 px-4 text-sm text-gray-500">No apps found</div>
|
||||
) : (
|
||||
<SidebarMenu className="space-y-1">
|
||||
{apps.map((app) => (
|
||||
<SidebarMenuItem key={app.id} className="mb-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleAppClick(app.id)}
|
||||
className={`justify-start w-full text-left py-3 hover:bg-sidebar-accent/80 ${
|
||||
selectedAppId === app.id
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<span className="truncate">{app.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDistanceToNow(new Date(app.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
140
src/components/ChatList.tsx
Normal file
140
src/components/ChatList.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import type { ChatSummary } from "@/lib/schemas";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import { useAtom } from "jotai";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { showError } from "@/lib/toast";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useChats } from "@/hooks/useChats";
|
||||
|
||||
export function ChatList({ show }: { show?: boolean }) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
|
||||
const [selectedAppId, setSelectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const { chats, loading, refreshChats } = useChats(selectedAppId);
|
||||
const routerState = useRouterState();
|
||||
const isChatRoute = routerState.location.pathname === "/chat";
|
||||
|
||||
// Update selectedChatId when route changes
|
||||
useEffect(() => {
|
||||
if (isChatRoute) {
|
||||
const id = routerState.location.search.id;
|
||||
if (id) {
|
||||
setSelectedChatId(id);
|
||||
}
|
||||
}
|
||||
}, [isChatRoute, routerState.location.search, setSelectedChatId]);
|
||||
|
||||
if (!show) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleChatClick = ({
|
||||
chatId,
|
||||
appId,
|
||||
}: {
|
||||
chatId: number;
|
||||
appId: number;
|
||||
}) => {
|
||||
setSelectedChatId(chatId);
|
||||
setSelectedAppId(appId);
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search: { id: chatId },
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewChat = async () => {
|
||||
// Only create a new chat if an app is selected
|
||||
if (selectedAppId) {
|
||||
try {
|
||||
// Create a new chat with an empty title for now
|
||||
const chatId = await IpcClient.getInstance().createChat(selectedAppId);
|
||||
|
||||
// Navigate to the new chat
|
||||
setSelectedChatId(chatId);
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search: { id: chatId },
|
||||
});
|
||||
|
||||
// Refresh the chat list
|
||||
await refreshChats();
|
||||
} catch (error) {
|
||||
// DO A TOAST
|
||||
showError(`Failed to create new chat: ${(error as any).toString()}`);
|
||||
}
|
||||
} else {
|
||||
// If no app is selected, navigate to home page
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarGroup className="overflow-y-auto h-[calc(100vh-112px)]">
|
||||
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Button
|
||||
onClick={handleNewChat}
|
||||
variant="outline"
|
||||
className="flex items-center justify-start gap-2 mx-2 py-3"
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
<span>New Chat</span>
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-3 px-4 text-sm text-gray-500">
|
||||
Loading chats...
|
||||
</div>
|
||||
) : chats.length === 0 ? (
|
||||
<div className="py-3 px-4 text-sm text-gray-500">
|
||||
No chats found
|
||||
</div>
|
||||
) : (
|
||||
<SidebarMenu className="space-y-1">
|
||||
{chats.map((chat) => (
|
||||
<SidebarMenuItem key={chat.id} className="mb-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
handleChatClick({ chatId: chat.id, appId: chat.appId })
|
||||
}
|
||||
className={`justify-start w-full text-left py-3 hover:bg-sidebar-accent/80 ${
|
||||
selectedChatId === chat.id
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<span className="truncate">
|
||||
{chat.title || "New Chat"}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDistanceToNow(new Date(chat.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
163
src/components/ChatPanel.tsx
Normal file
163
src/components/ChatPanel.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { chatMessagesAtom, chatStreamCountAtom } from "../atoms/chatAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { ChatHeader } from "./chat/ChatHeader";
|
||||
import { MessagesList } from "./chat/MessagesList";
|
||||
import { ChatInput } from "./chat/ChatInput";
|
||||
import { VersionPane } from "./chat/VersionPane";
|
||||
import { ChatError } from "./chat/ChatError";
|
||||
|
||||
interface ChatPanelProps {
|
||||
chatId?: number;
|
||||
isPreviewOpen: boolean;
|
||||
onTogglePreview: () => void;
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
chatId,
|
||||
isPreviewOpen,
|
||||
onTogglePreview,
|
||||
}: ChatPanelProps) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const [messages, setMessages] = useAtom(chatMessagesAtom);
|
||||
const [appName, setAppName] = useState<string>("Chat");
|
||||
const [isVersionPaneOpen, setIsVersionPaneOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const streamCount = useAtomValue(chatStreamCountAtom);
|
||||
// Reference to store the processed prompt so we don't submit it twice
|
||||
const processedPromptRef = useRef<string | null>(null);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Scroll-related properties
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
const userScrollTimeoutRef = useRef<number | null>(null);
|
||||
const lastScrollTopRef = useRef<number>(0);
|
||||
|
||||
const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior });
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!messagesContainerRef.current) return;
|
||||
|
||||
const container = messagesContainerRef.current;
|
||||
const currentScrollTop = container.scrollTop;
|
||||
|
||||
if (currentScrollTop < lastScrollTopRef.current) {
|
||||
setIsUserScrolling(true);
|
||||
|
||||
if (userScrollTimeoutRef.current) {
|
||||
window.clearTimeout(userScrollTimeoutRef.current);
|
||||
}
|
||||
|
||||
userScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsUserScrolling(false);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
lastScrollTopRef.current = currentScrollTop;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log("streamCount", streamCount);
|
||||
scrollToBottom();
|
||||
}, [streamCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener("scroll", handleScroll, { passive: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (container) {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
}
|
||||
if (userScrollTimeoutRef.current) {
|
||||
window.clearTimeout(userScrollTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAppName = async () => {
|
||||
if (!appId) return;
|
||||
|
||||
try {
|
||||
const app = await IpcClient.getInstance().getApp(appId);
|
||||
if (app?.name) {
|
||||
setAppName(app.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch app name:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAppName();
|
||||
}, [appId]);
|
||||
|
||||
const fetchChatMessages = useCallback(async () => {
|
||||
if (!chatId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
const chat = await IpcClient.getInstance().getChat(chatId);
|
||||
setMessages(chat.messages);
|
||||
}, [chatId, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchChatMessages();
|
||||
}, [fetchChatMessages]);
|
||||
|
||||
// Auto-scroll effect when messages change
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isUserScrolling &&
|
||||
messagesContainerRef.current &&
|
||||
messages.length > 0
|
||||
) {
|
||||
const { scrollTop, clientHeight, scrollHeight } =
|
||||
messagesContainerRef.current;
|
||||
const threshold = 280;
|
||||
const isNearBottom =
|
||||
scrollHeight - (scrollTop + clientHeight) <= threshold;
|
||||
|
||||
if (isNearBottom) {
|
||||
requestAnimationFrame(() => {
|
||||
scrollToBottom("instant");
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [messages, isUserScrolling]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ChatHeader
|
||||
isPreviewOpen={isPreviewOpen}
|
||||
onTogglePreview={onTogglePreview}
|
||||
onVersionClick={() => setIsVersionPaneOpen(!isVersionPaneOpen)}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{!isVersionPaneOpen && (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<MessagesList
|
||||
messages={messages}
|
||||
messagesEndRef={messagesEndRef}
|
||||
ref={messagesContainerRef}
|
||||
/>
|
||||
<ChatError error={error} onDismiss={() => setError(null)} />
|
||||
<ChatInput chatId={chatId} />
|
||||
</div>
|
||||
)}
|
||||
<VersionPane
|
||||
isVisible={isVersionPaneOpen}
|
||||
onClose={() => setIsVersionPaneOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/ConfirmationDialog.tsx
Normal file
84
src/components/ConfirmationDialog.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
|
||||
interface ConfirmationDialogProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
confirmButtonClass?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmationDialog({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
confirmButtonClass = "bg-red-600 hover:bg-red-700 focus:ring-red-500",
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmationDialogProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex min-h-screen items-center justify-center p-4 text-center sm:p-0">
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
<div className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div className="bg-white dark:bg-gray-800 px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg
|
||||
className="h-6 w-6 text-red-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm ${confirmButtonClass}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-3 inline-flex w-full justify-center rounded-md border border-gray-300 bg-white dark:bg-gray-600 dark:border-gray-500 dark:text-gray-200 px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/components/ModelPicker.tsx
Normal file
93
src/components/ModelPicker.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { LargeLanguageModel, ModelProvider } from "@/lib/schemas";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useState } from "react";
|
||||
import { MODEL_OPTIONS } from "@/constants/models";
|
||||
|
||||
interface ModelPickerProps {
|
||||
selectedModel: LargeLanguageModel;
|
||||
onModelSelect: (model: LargeLanguageModel) => void;
|
||||
}
|
||||
|
||||
export function ModelPicker({
|
||||
selectedModel,
|
||||
onModelSelect,
|
||||
}: ModelPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const modelDisplayName = MODEL_OPTIONS[selectedModel.provider].find(
|
||||
(model) => model.name === selectedModel.name
|
||||
)?.displayName;
|
||||
|
||||
// Flatten the model options into a single array with provider information
|
||||
const allModels = Object.entries(MODEL_OPTIONS).flatMap(
|
||||
([provider, models]) =>
|
||||
models.map((model) => ({
|
||||
...model,
|
||||
provider: provider as ModelProvider,
|
||||
}))
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 h-8"
|
||||
>
|
||||
<span>
|
||||
<span className="text-xs text-muted-foreground">Model:</span>{" "}
|
||||
{modelDisplayName}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-56 p-1" align="start">
|
||||
<div className="grid gap-2">
|
||||
{allModels.map((model) => (
|
||||
<Tooltip key={model.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={
|
||||
selectedModel.name === model.name ? "secondary" : "ghost"
|
||||
}
|
||||
className="w-full justify-start font-normal"
|
||||
onClick={() => {
|
||||
onModelSelect({
|
||||
name: model.name,
|
||||
provider: model.provider,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<span className="flex flex-col items-start">
|
||||
<span>{model.displayName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.provider}
|
||||
</span>
|
||||
</span>
|
||||
{model.tag && (
|
||||
<span className="text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium">
|
||||
{model.tag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{model.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
75
src/components/ProviderSettings.tsx
Normal file
75
src/components/ProviderSettings.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { PROVIDERS } from "@/constants/models";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
|
||||
import type { ModelProvider } from "@/lib/schemas";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { GiftIcon } from "lucide-react";
|
||||
interface ProviderSettingsProps {
|
||||
configuredProviders?: ModelProvider[];
|
||||
}
|
||||
|
||||
export function ProviderSettingsGrid({
|
||||
configuredProviders = [],
|
||||
}: ProviderSettingsProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleProviderClick = (provider: ModelProvider) => {
|
||||
console.log("PROVIDER", provider);
|
||||
navigate({
|
||||
to: providerSettingsRoute.id,
|
||||
params: { provider },
|
||||
});
|
||||
};
|
||||
|
||||
const { isProviderSetup } = useSettings();
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h2 className="text-2xl font-bold mb-6">AI Providers</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Object.entries(PROVIDERS).map(([key, provider]) => {
|
||||
const isConfigured = configuredProviders.includes(
|
||||
key as ModelProvider
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={key}
|
||||
className="cursor-pointer transition-all hover:shadow-md border-border"
|
||||
onClick={() => handleProviderClick(key as ModelProvider)}
|
||||
>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-xl flex items-center justify-between">
|
||||
{provider.displayName}
|
||||
{isProviderSetup(key) ? (
|
||||
<span className="ml-3 text-sm font-medium text-green-500 bg-green-50 dark:bg-green-900/30 border border-green-500/50 dark:border-green-500/50 px-2 py-1 rounded-full">
|
||||
Ready
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 bg-gray-50 dark:bg-gray-900 dark:text-gray-300 px-2 py-1 rounded-full">
|
||||
Needs Setup
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{provider.hasFreeTier && (
|
||||
<span className="text-blue-600 mt-2 dark:text-blue-400 text-sm font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded-full inline-flex items-center">
|
||||
<GiftIcon className="w-4 h-4 mr-1" />
|
||||
Free tier available
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/components/SetupBanner.tsx
Normal file
39
src/components/SetupBanner.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRight, GiftIcon, Sparkles } from "lucide-react";
|
||||
import { providerSettingsRoute } from "@/routes/settings/providers/$provider";
|
||||
|
||||
export function SetupBanner() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSetupClick = () => {
|
||||
navigate({
|
||||
to: providerSettingsRoute.id,
|
||||
params: { provider: "google" },
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full mb-8 p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-xl shadow-sm cursor-pointer hover:bg-blue-100 dark:hover:bg-blue-900/40 transition-colors"
|
||||
onClick={handleSetupClick}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-blue-100 dark:bg-blue-800 p-2 rounded-full">
|
||||
<Sparkles className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-blue-800 dark:text-blue-300">
|
||||
Setup your AI API access
|
||||
</h3>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400 flex items-center gap-1">
|
||||
<GiftIcon className="w-3.5 h-3.5" />
|
||||
Use Google Gemini for free
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/components/app-sidebar.tsx
Normal file
174
src/components/app-sidebar.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { Home, Inbox, Settings } from "lucide-react";
|
||||
import { Link, useRouterState } from "@tanstack/react-router";
|
||||
import { useSidebar } from "@/components/ui/sidebar"; // import useSidebar hook
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ChatList } from "./ChatList";
|
||||
import { AppList } from "./AppList";
|
||||
|
||||
// Menu items.
|
||||
const items = [
|
||||
{
|
||||
title: "Apps",
|
||||
to: "/",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: "Chat",
|
||||
to: "/chat",
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
to: "/settings",
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
// Hover state types
|
||||
type HoverState =
|
||||
| "start-hover:app"
|
||||
| "start-hover:chat"
|
||||
| "clear-hover"
|
||||
| "no-hover";
|
||||
|
||||
export function AppSidebar() {
|
||||
const { state, toggleSidebar } = useSidebar(); // retrieve current sidebar state
|
||||
const [hoverState, setHoverState] = useState<HoverState>("no-hover");
|
||||
const expandedByHover = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(hoverState === "start-hover:app" || hoverState === "start-hover:chat") &&
|
||||
state === "collapsed"
|
||||
) {
|
||||
expandedByHover.current = true;
|
||||
toggleSidebar();
|
||||
}
|
||||
if (
|
||||
hoverState === "clear-hover" &&
|
||||
state === "expanded" &&
|
||||
expandedByHover.current
|
||||
) {
|
||||
toggleSidebar();
|
||||
expandedByHover.current = false;
|
||||
setHoverState("no-hover");
|
||||
}
|
||||
}, [hoverState, toggleSidebar, state, setHoverState]);
|
||||
|
||||
const routerState = useRouterState();
|
||||
const isAppRoute =
|
||||
routerState.location.pathname === "/" ||
|
||||
routerState.location.pathname.startsWith("/app-details");
|
||||
const isChatRoute = routerState.location.pathname === "/chat";
|
||||
|
||||
let selectedItem: string | null = null;
|
||||
if (hoverState === "start-hover:app") {
|
||||
selectedItem = "Apps";
|
||||
} else if (hoverState === "start-hover:chat") {
|
||||
selectedItem = "Chat";
|
||||
} else if (state === "expanded") {
|
||||
if (isAppRoute) {
|
||||
selectedItem = "Apps";
|
||||
} else if (isChatRoute) {
|
||||
selectedItem = "Chat";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
collapsible="icon"
|
||||
onMouseLeave={() => {
|
||||
setHoverState("clear-hover");
|
||||
}}
|
||||
>
|
||||
<SidebarContent className="overflow-hidden">
|
||||
<div className="flex mt-8">
|
||||
{/* Left Column: Menu items */}
|
||||
<div className="">
|
||||
<SidebarTrigger
|
||||
onMouseEnter={() => {
|
||||
setHoverState("clear-hover");
|
||||
}}
|
||||
/>
|
||||
<AppIcons onHoverChange={setHoverState} />
|
||||
</div>
|
||||
{/* Right Column: Chat List Section */}
|
||||
<div className="w-[240px]">
|
||||
<AppList show={selectedItem === "Apps"} />
|
||||
<ChatList show={selectedItem === "Chat"} />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
function AppIcons({
|
||||
onHoverChange,
|
||||
}: {
|
||||
onHoverChange: (state: HoverState) => void;
|
||||
}) {
|
||||
const routerState = useRouterState();
|
||||
const pathname = routerState.location.pathname;
|
||||
|
||||
return (
|
||||
// When collapsed: only show the main menu
|
||||
<SidebarGroup className="pr-0">
|
||||
{/* <SidebarGroupLabel>Dyad</SidebarGroupLabel> */}
|
||||
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
(item.to === "/" && pathname === "/") ||
|
||||
(item.to !== "/" && pathname.startsWith(item.to));
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
size="sm"
|
||||
className="font-medium w-14"
|
||||
>
|
||||
<Link
|
||||
to={item.to}
|
||||
className={`flex flex-col items-center gap-1 h-14 mb-2 rounded-2xl ${
|
||||
isActive ? "bg-sidebar-accent" : ""
|
||||
}`}
|
||||
onMouseEnter={() => {
|
||||
if (item.title === "Apps") {
|
||||
onHoverChange("start-hover:app");
|
||||
} else if (item.title === "Chat") {
|
||||
onHoverChange("start-hover:chat");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span className={"text-xs"}>{item.title}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
30
src/components/chat/ChatError.tsx
Normal file
30
src/components/chat/ChatError.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
import { XCircle, AlertTriangle } from "lucide-react"; // Assuming lucide-react is used
|
||||
|
||||
interface ChatErrorProps {
|
||||
error: string | null;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function ChatError({ error, onDismiss }: ChatErrorProps) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex items-start text-red-600 bg-red-100 border border-red-500 rounded-md text-sm p-3 mx-4 mb-2 shadow-sm">
|
||||
<AlertTriangle
|
||||
className="h-5 w-5 mr-2 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex-1">{error}</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="absolute top-1 right-1 p-1 rounded-full hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-red-400"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-red-500 hover:text-red-700" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/components/chat/ChatHeader.tsx
Normal file
88
src/components/chat/ChatHeader.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { PanelRightOpen, History, PlusCircle } from "lucide-react";
|
||||
import { PanelRightClose } from "lucide-react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadVersions } from "@/hooks/useLoadVersions";
|
||||
import { Button } from "../ui/button";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useChats } from "@/hooks/useChats";
|
||||
import { showError } from "@/lib/toast";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
isPreviewOpen: boolean;
|
||||
onTogglePreview: () => void;
|
||||
onVersionClick: () => void;
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
isPreviewOpen,
|
||||
onTogglePreview,
|
||||
onVersionClick,
|
||||
}: ChatHeaderProps) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { versions, loading } = useLoadVersions(appId);
|
||||
const { navigate } = useRouter();
|
||||
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
|
||||
const { refreshChats } = useChats(appId);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
// Only create a new chat if an app is selected
|
||||
if (appId) {
|
||||
try {
|
||||
// Create a new chat with an empty title for now
|
||||
const chatId = await IpcClient.getInstance().createChat(appId);
|
||||
|
||||
// Navigate to the new chat
|
||||
setSelectedChatId(chatId);
|
||||
navigate({
|
||||
to: "/chat",
|
||||
search: { id: chatId },
|
||||
});
|
||||
|
||||
// Refresh the chat list
|
||||
await refreshChats();
|
||||
} catch (error) {
|
||||
// DO A TOAST
|
||||
showError(`Failed to create new chat: ${(error as any).toString()}`);
|
||||
}
|
||||
} else {
|
||||
// If no app is selected, navigate to home page
|
||||
navigate({ to: "/" });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="@container flex items-center justify-between py-1.5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={handleNewChat}
|
||||
variant="ghost"
|
||||
className="hidden @2xs:flex items-center justify-start gap-2 mx-2 py-3"
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
<span>New Chat</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onVersionClick}
|
||||
variant="ghost"
|
||||
className="hidden @6xs:flex cursor-pointer items-center gap-1 text-sm px-2 py-1 rounded-md"
|
||||
>
|
||||
<History size={16} />
|
||||
{loading ? "..." : `Version ${versions.length}`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onTogglePreview}
|
||||
className="cursor-pointer p-2 hover:bg-(--background-lightest) rounded-md"
|
||||
>
|
||||
{isPreviewOpen ? (
|
||||
<PanelRightClose size={20} />
|
||||
) : (
|
||||
<PanelRightOpen size={20} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
src/components/chat/ChatInput.tsx
Normal file
139
src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { SendIcon, StopCircleIcon, X } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ModelPicker } from "@/components/ModelPicker";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { chatInputValueAtom } from "@/atoms/chatAtoms";
|
||||
import { useAtom } from "jotai";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { useChats } from "@/hooks/useChats";
|
||||
import { selectedAppIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
|
||||
interface ChatInputProps {
|
||||
chatId?: number;
|
||||
onSubmit?: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({ chatId, onSubmit }: ChatInputProps) {
|
||||
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const { streamMessage, isStreaming, setIsStreaming, error, setError } =
|
||||
useStreamChat();
|
||||
const [selectedAppId] = useAtom(selectedAppIdAtom);
|
||||
const [showError, setShowError] = useState(true);
|
||||
|
||||
const adjustHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "0px";
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
console.log("scrollHeight", scrollHeight);
|
||||
textarea.style.height = `${scrollHeight + 4}px`;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [inputValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setShowError(true);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submitHandler();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!inputValue.trim() || isStreaming || !chatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentInput = inputValue;
|
||||
setInputValue("");
|
||||
await streamMessage({ prompt: currentInput, chatId });
|
||||
};
|
||||
const submitHandler = onSubmit ? onSubmit : handleSubmit;
|
||||
|
||||
const handleCancel = () => {
|
||||
if (chatId) {
|
||||
IpcClient.getInstance().cancelChatStream(chatId);
|
||||
}
|
||||
setIsStreaming(false);
|
||||
};
|
||||
|
||||
const dismissError = () => {
|
||||
setShowError(false);
|
||||
};
|
||||
|
||||
if (!settings) {
|
||||
return null; // Or loading state
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && showError && (
|
||||
<div className="relative mt-2 bg-red-50 border border-red-200 rounded-md shadow-sm p-2">
|
||||
<button
|
||||
onClick={dismissError}
|
||||
className="absolute top-1 left-1 p-1 hover:bg-red-100 rounded"
|
||||
>
|
||||
<X size={14} className="text-red-500" />
|
||||
</button>
|
||||
<div className="px-6 py-1 text-sm">
|
||||
<div className="text-red-700 text-wrap">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col space-y-2 border border-border rounded-lg bg-(--background-lighter) shadow-sm">
|
||||
<div className="flex items-start space-x-2 ">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Ask Dyad to build..."
|
||||
className="flex-1 p-2 focus:outline-none overflow-y-auto min-h-[40px] max-h-[200px]"
|
||||
style={{ resize: "none" }}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg"
|
||||
title="Cancel generation"
|
||||
>
|
||||
<StopCircleIcon size={20} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={submitHandler}
|
||||
disabled={!inputValue.trim()}
|
||||
className="px-2 py-2 mt-1 mr-2 hover:bg-(--background-darkest) text-(--sidebar-accent-fg) rounded-lg disabled:opacity-50"
|
||||
>
|
||||
<SendIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2 pb-2">
|
||||
<ModelPicker
|
||||
selectedModel={settings.selectedModel}
|
||||
onModelSelect={(model) =>
|
||||
updateSettings({ selectedModel: model })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
80
src/components/chat/ChatMessage.tsx
Normal file
80
src/components/chat/ChatMessage.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { memo } from "react";
|
||||
import type { Message } from "ai";
|
||||
import { DyadMarkdownParser } from "./DyadMarkdownParser";
|
||||
import { motion } from "framer-motion";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
const ChatMessage = memo(
|
||||
({ message }: ChatMessageProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex ${
|
||||
message.role === "assistant" ? "justify-start" : "justify-end"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`rounded-lg p-2 mt-2 ${
|
||||
message.role === "assistant"
|
||||
? "w-full max-w-3xl mx-auto"
|
||||
: "bg-(--sidebar-accent)"
|
||||
}`}
|
||||
>
|
||||
{message.role === "assistant" && !message.content ? (
|
||||
<div className="flex h-6 items-center space-x-2 p-2">
|
||||
<motion.div
|
||||
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
|
||||
animate={{ y: [0, -12, 0] }}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
repeatDelay: 1.2,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
|
||||
animate={{ y: [0, -12, 0] }}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
delay: 0.4,
|
||||
repeatDelay: 1.2,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
className="h-3 w-3 rounded-full bg-(--primary) dark:bg-blue-500"
|
||||
animate={{ y: [0, -12, 0] }}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration: 0.4,
|
||||
ease: "easeOut",
|
||||
delay: 0.8,
|
||||
repeatDelay: 1.2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="prose dark:prose-invert prose-headings:mb-2 prose-p:my-1 prose-pre:my-0 max-w-none"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<DyadMarkdownParser content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.message.content === nextProps.message.content;
|
||||
}
|
||||
);
|
||||
|
||||
ChatMessage.displayName = "ChatMessage";
|
||||
|
||||
export default ChatMessage;
|
||||
89
src/components/chat/CodeHighlight.tsx
Normal file
89
src/components/chat/CodeHighlight.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useEffect, useRef, memo, type ReactNode } from "react";
|
||||
import { isInlineCode, useShikiHighlighter } from "react-shiki";
|
||||
import github from "@shikijs/themes/github-light-default";
|
||||
import githubDark from "@shikijs/themes/github-dark-default";
|
||||
import type { Element as HastElement } from "hast";
|
||||
import { useTheme } from "../../contexts/ThemeContext";
|
||||
|
||||
interface CodeHighlightProps {
|
||||
className?: string | undefined;
|
||||
children?: ReactNode | undefined;
|
||||
node?: HastElement | undefined;
|
||||
}
|
||||
|
||||
export const CodeHighlight = memo(
|
||||
({ className, children, node, ...props }: CodeHighlightProps) => {
|
||||
const code = String(children).trim();
|
||||
const language = className?.match(/language-(\w+)/)?.[1];
|
||||
const isInline = node ? isInlineCode(node) : false;
|
||||
|
||||
// Get the current theme setting
|
||||
const { theme } = useTheme();
|
||||
|
||||
// State to track if dark mode is active
|
||||
const [isDarkMode, setIsDarkMode] = React.useState(false);
|
||||
|
||||
// Determine if dark mode is active when component mounts or theme changes
|
||||
useEffect(() => {
|
||||
const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const updateTheme = () => {
|
||||
setIsDarkMode(
|
||||
theme === "dark" || (theme === "system" && darkModeQuery.matches)
|
||||
);
|
||||
};
|
||||
|
||||
updateTheme();
|
||||
darkModeQuery.addEventListener("change", updateTheme);
|
||||
|
||||
return () => {
|
||||
darkModeQuery.removeEventListener("change", updateTheme);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
// Cache for the highlighted code
|
||||
const highlightedCodeCache = useRef<ReactNode | null>(null);
|
||||
|
||||
// Only update the highlighted code if the inputs change
|
||||
const highlightedCode = useShikiHighlighter(
|
||||
code,
|
||||
language,
|
||||
isDarkMode ? githubDark : github,
|
||||
{
|
||||
delay: 150,
|
||||
}
|
||||
);
|
||||
|
||||
// Update the cache whenever we get a new highlighted code
|
||||
useEffect(() => {
|
||||
if (highlightedCode) {
|
||||
highlightedCodeCache.current = highlightedCode;
|
||||
}
|
||||
}, [highlightedCode]);
|
||||
|
||||
// Use the cached version during transitions to prevent flickering
|
||||
const displayedCode = highlightedCode || highlightedCodeCache.current;
|
||||
return !isInline ? (
|
||||
<div
|
||||
className="shiki not-prose relative [&_pre]:overflow-auto
|
||||
[&_pre]:rounded-lg [&_pre]:px-6 [&_pre]:py-5"
|
||||
>
|
||||
{language ? (
|
||||
<span
|
||||
className="absolute right-3 top-2 text-xs tracking-tighter
|
||||
text-muted-foreground/85"
|
||||
>
|
||||
{language}
|
||||
</span>
|
||||
) : null}
|
||||
{displayedCode}
|
||||
</div>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.children === nextProps.children;
|
||||
}
|
||||
);
|
||||
166
src/components/chat/DyadAddDependency.tsx
Normal file
166
src/components/chat/DyadAddDependency.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../ui/button";
|
||||
import { IpcClient } from "../../ipc/ipc_client";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { chatMessagesAtom, selectedChatIdAtom } from "../../atoms/chatAtoms";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import {
|
||||
Package,
|
||||
ChevronsUpDown,
|
||||
ChevronsDownUp,
|
||||
Loader,
|
||||
ExternalLink,
|
||||
Download,
|
||||
} from "lucide-react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
|
||||
interface DyadAddDependencyProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
packages?: string;
|
||||
}
|
||||
|
||||
export const DyadAddDependency: React.FC<DyadAddDependencyProps> = ({
|
||||
children,
|
||||
node,
|
||||
}) => {
|
||||
// Extract package attribute from the node if available
|
||||
const packages = node?.properties?.packages?.split(" ") || "";
|
||||
console.log("packages", packages);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
const [messages, setMessages] = useAtom(chatMessagesAtom);
|
||||
const { streamMessage, isStreaming } = useStreamChat();
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
const hasChildren = !!children;
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!packages || !selectedChatId) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
setError(null);
|
||||
try {
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
|
||||
await ipcClient.addDependency({
|
||||
chatId: selectedChatId,
|
||||
packages,
|
||||
});
|
||||
|
||||
// Refresh the chat messages
|
||||
const chat = await IpcClient.getInstance().getChat(selectedChatId);
|
||||
setMessages(chat.messages);
|
||||
|
||||
await streamMessage({
|
||||
prompt: `I've installed ${packages.join(", ")}. Keep going.`,
|
||||
chatId: selectedChatId,
|
||||
});
|
||||
} catch (err) {
|
||||
setError("There was an error installing this package.");
|
||||
|
||||
const chat = await IpcClient.getInstance().getChat(selectedChatId);
|
||||
setMessages(chat.messages);
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-(--background-lightest) dark:bg-gray-900 hover:bg-(--background-lighter) rounded-lg px-4 py-3 border my-2 ${
|
||||
hasChildren ? "cursor-pointer" : ""
|
||||
} ${isInstalling ? "border-amber-500" : "border-border"}`}
|
||||
onClick={
|
||||
hasChildren ? () => setIsContentVisible(!isContentVisible) : undefined
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package size={18} className="text-gray-600 dark:text-gray-400" />
|
||||
{packages.length > 0 && (
|
||||
<div className="text-gray-800 dark:text-gray-200 font-semibold text-base">
|
||||
<div className="font-normal">
|
||||
Do you want to install these packages?
|
||||
</div>{" "}
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{packages.map((p: string) => (
|
||||
<span
|
||||
className="cursor-pointer text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
key={p}
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().openExternalUrl(
|
||||
`https://www.npmjs.com/package/${p}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isInstalling && (
|
||||
<div className="flex items-center text-amber-600 text-xs ml-2">
|
||||
<Loader size={14} className="mr-1 animate-spin" />
|
||||
<span>Installing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && (
|
||||
<div className="flex items-center">
|
||||
{isContentVisible ? (
|
||||
<ChevronsDownUp
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{packages.length > 0 && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
Make sure these packages are what you want.{" "}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show content if it's visible and has children */}
|
||||
{isContentVisible && hasChildren && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs">
|
||||
<CodeHighlight className="language-shell">{children}</CodeHighlight>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Always show install button if there are no children */}
|
||||
{packages.length > 0 && !hasChildren && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
if (hasChildren) e.stopPropagation();
|
||||
handleInstall();
|
||||
}}
|
||||
disabled={isInstalling || isStreaming}
|
||||
size="default"
|
||||
variant="default"
|
||||
className="font-medium bg-primary/90 flex items-center gap-2 w-full max-w-sm py-4 mt-2 mb-2"
|
||||
>
|
||||
<Download size={16} />
|
||||
{isInstalling ? "Installing..." : "Install packages"}
|
||||
</Button>
|
||||
|
||||
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
src/components/chat/DyadDelete.tsx
Normal file
45
src/components/chat/DyadDelete.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Trash2 } from "lucide-react";
|
||||
|
||||
interface DyadDeleteProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export const DyadDelete: React.FC<DyadDeleteProps> = ({
|
||||
children,
|
||||
node,
|
||||
path: pathProp,
|
||||
}) => {
|
||||
// Use props directly if provided, otherwise extract from node
|
||||
const path = pathProp || node?.properties?.path || "";
|
||||
|
||||
// Extract filename from path
|
||||
const fileName = path ? path.split("/").pop() : "";
|
||||
|
||||
return (
|
||||
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border border-red-500 my-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 size={16} className="text-red-500" />
|
||||
{fileName && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-red-500 font-medium">Delete</div>
|
||||
</div>
|
||||
</div>
|
||||
{path && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
|
||||
{path}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
285
src/components/chat/DyadMarkdownParser.tsx
Normal file
285
src/components/chat/DyadMarkdownParser.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React, { useMemo } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import { DyadWrite } from "./DyadWrite";
|
||||
import { DyadRename } from "./DyadRename";
|
||||
import { DyadDelete } from "./DyadDelete";
|
||||
import { DyadAddDependency } from "./DyadAddDependency";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { isStreamingAtom } from "@/atoms/chatAtoms";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
|
||||
interface DyadMarkdownParserProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
type CustomTagInfo = {
|
||||
tag: string;
|
||||
attributes: Record<string, string>;
|
||||
content: string;
|
||||
fullMatch: string;
|
||||
inProgress?: boolean;
|
||||
};
|
||||
|
||||
type ContentPiece =
|
||||
| { type: "markdown"; content: string }
|
||||
| { type: "custom-tag"; tagInfo: CustomTagInfo };
|
||||
|
||||
/**
|
||||
* Custom component to parse markdown content with Dyad-specific tags
|
||||
*/
|
||||
export const DyadMarkdownParser: React.FC<DyadMarkdownParserProps> = ({
|
||||
content,
|
||||
}) => {
|
||||
const isStreaming = useAtomValue(isStreamingAtom);
|
||||
// Extract content pieces (markdown and custom tags)
|
||||
const contentPieces = useMemo(() => {
|
||||
return parseCustomTags(content);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentPieces.map((piece, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{piece.type === "markdown"
|
||||
? piece.content && (
|
||||
<ReactMarkdown
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{ code: CodeHighlight } as any}
|
||||
>
|
||||
{piece.content}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
: renderCustomTag(piece.tagInfo, { isStreaming })}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-process content to handle unclosed custom tags
|
||||
* Adds closing tags at the end of the content for any unclosed custom tags
|
||||
* Assumes the opening tags are complete and valid
|
||||
* Returns the processed content and a map of in-progress tags
|
||||
*/
|
||||
function preprocessUnclosedTags(content: string): {
|
||||
processedContent: string;
|
||||
inProgressTags: Map<string, Set<number>>;
|
||||
} {
|
||||
const customTagNames = [
|
||||
"dyad-write",
|
||||
"dyad-rename",
|
||||
"dyad-delete",
|
||||
"dyad-add-dependency",
|
||||
];
|
||||
|
||||
let processedContent = content;
|
||||
// Map to track which tags are in progress and their positions
|
||||
const inProgressTags = new Map<string, Set<number>>();
|
||||
|
||||
// For each tag type, check if there are unclosed tags
|
||||
for (const tagName of customTagNames) {
|
||||
// Count opening and closing tags
|
||||
const openTagPattern = new RegExp(`<${tagName}(?:\\s[^>]*)?>`, "g");
|
||||
const closeTagPattern = new RegExp(`</${tagName}>`, "g");
|
||||
|
||||
// Track the positions of opening tags
|
||||
const openingMatches: RegExpExecArray[] = [];
|
||||
let match;
|
||||
|
||||
// Reset regex lastIndex to start from the beginning
|
||||
openTagPattern.lastIndex = 0;
|
||||
|
||||
while ((match = openTagPattern.exec(processedContent)) !== null) {
|
||||
openingMatches.push({ ...match });
|
||||
}
|
||||
|
||||
const openCount = openingMatches.length;
|
||||
const closeCount = (processedContent.match(closeTagPattern) || []).length;
|
||||
|
||||
// If we have more opening than closing tags
|
||||
const missingCloseTags = openCount - closeCount;
|
||||
if (missingCloseTags > 0) {
|
||||
// Add the required number of closing tags at the end
|
||||
processedContent += Array(missingCloseTags)
|
||||
.fill(`</${tagName}>`)
|
||||
.join("");
|
||||
|
||||
// Mark the last N tags as in progress where N is the number of missing closing tags
|
||||
const inProgressIndexes = new Set<number>();
|
||||
const startIndex = openCount - missingCloseTags;
|
||||
for (let i = startIndex; i < openCount; i++) {
|
||||
inProgressIndexes.add(openingMatches[i].index);
|
||||
}
|
||||
inProgressTags.set(tagName, inProgressIndexes);
|
||||
}
|
||||
}
|
||||
|
||||
return { processedContent, inProgressTags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the content to extract custom tags and markdown sections into a unified array
|
||||
*/
|
||||
function parseCustomTags(content: string): ContentPiece[] {
|
||||
const { processedContent, inProgressTags } = preprocessUnclosedTags(content);
|
||||
|
||||
const customTagNames = [
|
||||
"dyad-write",
|
||||
"dyad-rename",
|
||||
"dyad-delete",
|
||||
"dyad-add-dependency",
|
||||
];
|
||||
|
||||
const tagPattern = new RegExp(
|
||||
`<(${customTagNames.join("|")})\\s*([^>]*)>(.*?)<\\/\\1>`,
|
||||
"gs"
|
||||
);
|
||||
|
||||
const contentPieces: ContentPiece[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
// Find all custom tags
|
||||
while ((match = tagPattern.exec(processedContent)) !== null) {
|
||||
const [fullMatch, tag, attributesStr, tagContent] = match;
|
||||
const startIndex = match.index;
|
||||
|
||||
// Add the markdown content before this tag
|
||||
if (startIndex > lastIndex) {
|
||||
contentPieces.push({
|
||||
type: "markdown",
|
||||
content: processedContent.substring(lastIndex, startIndex),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse attributes
|
||||
const attributes: Record<string, string> = {};
|
||||
const attrPattern = /(\w+)="([^"]*)"/g;
|
||||
let attrMatch;
|
||||
while ((attrMatch = attrPattern.exec(attributesStr)) !== null) {
|
||||
attributes[attrMatch[1]] = attrMatch[2];
|
||||
}
|
||||
|
||||
// Check if this tag was marked as in progress
|
||||
const tagInProgressSet = inProgressTags.get(tag);
|
||||
const isInProgress = tagInProgressSet?.has(startIndex);
|
||||
|
||||
// Add the tag info
|
||||
contentPieces.push({
|
||||
type: "custom-tag",
|
||||
tagInfo: {
|
||||
tag,
|
||||
attributes,
|
||||
content: tagContent,
|
||||
fullMatch,
|
||||
inProgress: isInProgress || false,
|
||||
},
|
||||
});
|
||||
|
||||
lastIndex = startIndex + fullMatch.length;
|
||||
}
|
||||
|
||||
// Add the remaining markdown content
|
||||
if (lastIndex < processedContent.length) {
|
||||
contentPieces.push({
|
||||
type: "markdown",
|
||||
content: processedContent.substring(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return contentPieces;
|
||||
}
|
||||
|
||||
function getState({
|
||||
isStreaming,
|
||||
inProgress,
|
||||
}: {
|
||||
isStreaming?: boolean;
|
||||
inProgress?: boolean;
|
||||
}): CustomTagState {
|
||||
if (!inProgress) {
|
||||
return "finished";
|
||||
}
|
||||
return isStreaming ? "pending" : "aborted";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a custom tag based on its type
|
||||
*/
|
||||
function renderCustomTag(
|
||||
tagInfo: CustomTagInfo,
|
||||
{ isStreaming }: { isStreaming: boolean }
|
||||
): React.ReactNode {
|
||||
const { tag, attributes, content, inProgress } = tagInfo;
|
||||
|
||||
switch (tag) {
|
||||
case "dyad-write":
|
||||
return (
|
||||
<DyadWrite
|
||||
node={{
|
||||
properties: {
|
||||
path: attributes.path || "",
|
||||
description: attributes.description || "",
|
||||
state: getState({ isStreaming, inProgress }),
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadWrite>
|
||||
);
|
||||
|
||||
case "dyad-rename":
|
||||
return (
|
||||
<DyadRename
|
||||
node={{
|
||||
properties: {
|
||||
from: attributes.from || "",
|
||||
to: attributes.to || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadRename>
|
||||
);
|
||||
|
||||
case "dyad-delete":
|
||||
return (
|
||||
<DyadDelete
|
||||
node={{
|
||||
properties: {
|
||||
path: attributes.path || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadDelete>
|
||||
);
|
||||
|
||||
case "dyad-add-dependency":
|
||||
return (
|
||||
<DyadAddDependency
|
||||
node={{
|
||||
properties: {
|
||||
packages: attributes.packages || "",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DyadAddDependency>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract attribute values from className string
|
||||
*/
|
||||
function extractAttribute(className: string, attrName: string): string {
|
||||
const match = new RegExp(`${attrName}="([^"]*)"`, "g").exec(className);
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
61
src/components/chat/DyadRename.tsx
Normal file
61
src/components/chat/DyadRename.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { FileEdit } from "lucide-react";
|
||||
|
||||
interface DyadRenameProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export const DyadRename: React.FC<DyadRenameProps> = ({
|
||||
children,
|
||||
node,
|
||||
from: fromProp,
|
||||
to: toProp,
|
||||
}) => {
|
||||
// Use props directly if provided, otherwise extract from node
|
||||
const from = fromProp || node?.properties?.from || "";
|
||||
const to = toProp || node?.properties?.to || "";
|
||||
|
||||
// Extract filenames from paths
|
||||
const fromFileName = from ? from.split("/").pop() : "";
|
||||
const toFileName = to ? to.split("/").pop() : "";
|
||||
|
||||
return (
|
||||
<div className="bg-(--background-lightest) rounded-lg px-4 py-2 border border-amber-500 my-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileEdit size={16} className="text-amber-500" />
|
||||
{(fromFileName || toFileName) && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{fromFileName && toFileName
|
||||
? `${fromFileName} → ${toFileName}`
|
||||
: fromFileName || toFileName}
|
||||
</span>
|
||||
)}
|
||||
<div className="text-xs text-amber-500 font-medium">Rename</div>
|
||||
</div>
|
||||
</div>
|
||||
{(from || to) && (
|
||||
<div className="flex flex-col text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
|
||||
{from && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">From:</span>{" "}
|
||||
{from}
|
||||
</div>
|
||||
)}
|
||||
{to && (
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-400">To:</span> {to}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 mt-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
105
src/components/chat/DyadWrite.tsx
Normal file
105
src/components/chat/DyadWrite.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import type React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Pencil,
|
||||
Loader,
|
||||
CircleX,
|
||||
} from "lucide-react";
|
||||
import { CodeHighlight } from "./CodeHighlight";
|
||||
import { CustomTagState } from "./stateTypes";
|
||||
|
||||
interface DyadWriteProps {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const DyadWrite: React.FC<DyadWriteProps> = ({
|
||||
children,
|
||||
node,
|
||||
path: pathProp,
|
||||
description: descriptionProp,
|
||||
}) => {
|
||||
const [isContentVisible, setIsContentVisible] = useState(false);
|
||||
|
||||
// Use props directly if provided, otherwise extract from node
|
||||
const path = pathProp || node?.properties?.path || "";
|
||||
const description = descriptionProp || node?.properties?.description || "";
|
||||
const state = node?.properties?.state as CustomTagState;
|
||||
const inProgress = state === "pending";
|
||||
const aborted = state === "aborted";
|
||||
|
||||
// Extract filename from path
|
||||
const fileName = path ? path.split("/").pop() : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
|
||||
inProgress
|
||||
? "border-amber-500"
|
||||
: aborted
|
||||
? "border-red-500"
|
||||
: "border-border"
|
||||
}`}
|
||||
onClick={() => setIsContentVisible(!isContentVisible)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil size={16} />
|
||||
{fileName && (
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium text-sm">
|
||||
{fileName}
|
||||
</span>
|
||||
)}
|
||||
{inProgress && (
|
||||
<div className="flex items-center text-amber-600 text-xs">
|
||||
<Loader size={14} className="mr-1 animate-spin" />
|
||||
<span>Writing...</span>
|
||||
</div>
|
||||
)}
|
||||
{aborted && (
|
||||
<div className="flex items-center text-red-600 text-xs">
|
||||
<CircleX size={14} className="mr-1" />
|
||||
<span>Did not finish</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{isContentVisible ? (
|
||||
<ChevronsDownUp
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
) : (
|
||||
<ChevronsUpDown
|
||||
size={20}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{path && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 font-medium mb-1">
|
||||
{path}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="font-medium">Summary: </span>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{isContentVisible && (
|
||||
<div className="text-xs">
|
||||
<CodeHighlight className="language-typescript">
|
||||
{children}
|
||||
</CodeHighlight>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
src/components/chat/MessagesList.tsx
Normal file
71
src/components/chat/MessagesList.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import type React from "react";
|
||||
import type { Message } from "ai";
|
||||
import { forwardRef } from "react";
|
||||
import ChatMessage from "./ChatMessage";
|
||||
import { SetupBanner } from "../SetupBanner";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useStreamChat } from "@/hooks/useStreamChat";
|
||||
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface MessagesListProps {
|
||||
messages: Message[];
|
||||
messagesEndRef: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const MessagesList = forwardRef<HTMLDivElement, MessagesListProps>(
|
||||
function MessagesList({ messages, messagesEndRef }, ref) {
|
||||
const { streamMessage, isStreaming, error, setError } = useStreamChat();
|
||||
const { isAnyProviderSetup } = useSettings();
|
||||
const selectedChatId = useAtomValue(selectedChatIdAtom);
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4" ref={ref}>
|
||||
{messages.length > 0 ? (
|
||||
messages.map((message, index) => (
|
||||
<ChatMessage key={index} message={message} />
|
||||
))
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full max-w-2xl mx-auto">
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
No messages yet
|
||||
</div>
|
||||
{!isAnyProviderSetup() && <SetupBanner />}
|
||||
</div>
|
||||
)}
|
||||
{messages.length > 0 && !isStreaming && (
|
||||
<div className="flex max-w-3xl mx-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!selectedChatId) {
|
||||
console.error("No chat selected");
|
||||
return;
|
||||
}
|
||||
// Find the last user message
|
||||
const lastUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "user");
|
||||
if (!lastUserMessage) {
|
||||
console.error("No user message found");
|
||||
return;
|
||||
}
|
||||
streamMessage({
|
||||
prompt: lastUserMessage.content,
|
||||
chatId: selectedChatId,
|
||||
redo: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
134
src/components/chat/VersionPane.tsx
Normal file
134
src/components/chat/VersionPane.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { selectedAppIdAtom, selectedVersionIdAtom } from "@/atoms/appAtoms";
|
||||
import { useLoadVersions } from "@/hooks/useLoadVersions";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import type { Version } from "@/ipc/ipc_types";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface VersionPaneProps {
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function VersionPane({ isVisible, onClose }: VersionPaneProps) {
|
||||
const appId = useAtomValue(selectedAppIdAtom);
|
||||
const { versions, loading, refreshVersions } = useLoadVersions(appId);
|
||||
const [selectedVersionId, setSelectedVersionId] = useAtom(
|
||||
selectedVersionIdAtom
|
||||
);
|
||||
useEffect(() => {
|
||||
// Refresh versions in case the user updated versions outside of the app
|
||||
// (e.g. manually using git).
|
||||
// Avoid loading state which causes brief flash of loading state.
|
||||
refreshVersions();
|
||||
if (!isVisible && selectedVersionId) {
|
||||
setSelectedVersionId(null);
|
||||
IpcClient.getInstance().checkoutVersion({
|
||||
appId: appId!,
|
||||
versionId: "main",
|
||||
});
|
||||
}
|
||||
}, [isVisible, refreshVersions]);
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full border-t border-2 border-border w-full">
|
||||
<div className="p-2 border-b border-border flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold pl-2">Version History</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-(--background-lightest) rounded-md "
|
||||
aria-label="Close version pane"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto h-[calc(100%-60px)]">
|
||||
{loading ? (
|
||||
<div className="p-4 ">Loading versions...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="p-4 ">No versions available</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{versions.map((version: Version, index) => (
|
||||
<div
|
||||
key={version.oid}
|
||||
className={`px-4 py-2 hover:bg-(--background-lightest) cursor-pointer ${
|
||||
selectedVersionId === version.oid
|
||||
? "bg-(--background-lightest)"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
IpcClient.getInstance().checkoutVersion({
|
||||
appId: appId!,
|
||||
versionId: version.oid,
|
||||
});
|
||||
setSelectedVersionId(version.oid);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-xs">
|
||||
Version {versions.length - index}
|
||||
</span>
|
||||
<span className="text-xs opacity-90">
|
||||
{formatDistanceToNow(new Date(version.timestamp * 1000), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{version.message && (
|
||||
<p className="mt-1 text-sm">
|
||||
{version.message.startsWith(
|
||||
"Reverted all changes back to version "
|
||||
)
|
||||
? version.message.replace(
|
||||
/Reverted all changes back to version ([a-f0-9]+)/,
|
||||
(_, hash) => {
|
||||
const targetIndex = versions.findIndex(
|
||||
(v) => v.oid === hash
|
||||
);
|
||||
return targetIndex !== -1
|
||||
? `Reverted all changes back to version ${
|
||||
versions.length - targetIndex
|
||||
}`
|
||||
: version.message;
|
||||
}
|
||||
)
|
||||
: version.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedVersionId(null);
|
||||
await IpcClient.getInstance().revertVersion({
|
||||
appId: appId!,
|
||||
previousVersionId: version.oid,
|
||||
});
|
||||
refreshVersions();
|
||||
}}
|
||||
className={cn(
|
||||
"invisible mt-1 flex items-center gap-1 px-2 py-0.5 text-sm font-medium bg-(--primary) text-(--primary-foreground) hover:bg-background-lightest rounded-md transition-colors",
|
||||
selectedVersionId === version.oid && "visible"
|
||||
)}
|
||||
aria-label="Undo to latest version"
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
<span>Undo</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/components/chat/monaco.ts
Normal file
189
src/components/chat/monaco.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { editor } from "monaco-editor";
|
||||
|
||||
import { loader } from "@monaco-editor/react";
|
||||
|
||||
import * as monaco from "monaco-editor";
|
||||
// @ts-ignore
|
||||
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
|
||||
// @ts-ignore
|
||||
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
|
||||
// @ts-ignore
|
||||
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
|
||||
// @ts-ignore
|
||||
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
|
||||
// @ts-ignore
|
||||
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_, label) {
|
||||
if (label === "json") {
|
||||
return new jsonWorker();
|
||||
}
|
||||
if (label === "css" || label === "scss" || label === "less") {
|
||||
return new cssWorker();
|
||||
}
|
||||
if (label === "html" || label === "handlebars" || label === "razor") {
|
||||
return new htmlWorker();
|
||||
}
|
||||
if (label === "typescript" || label === "javascript") {
|
||||
return new tsWorker();
|
||||
}
|
||||
return new editorWorker();
|
||||
},
|
||||
};
|
||||
|
||||
loader.config({ monaco });
|
||||
|
||||
// loader.init().then(/* ... */);
|
||||
export const customLight: editor.IStandaloneThemeData = {
|
||||
base: "vs",
|
||||
inherit: false,
|
||||
rules: [
|
||||
{ token: "", foreground: "000000", background: "fffffe" },
|
||||
{ token: "invalid", foreground: "cd3131" },
|
||||
{ token: "emphasis", fontStyle: "italic" },
|
||||
{ token: "strong", fontStyle: "bold" },
|
||||
|
||||
{ token: "variable", foreground: "001188" },
|
||||
{ token: "variable.predefined", foreground: "4864AA" },
|
||||
{ token: "constant", foreground: "dd0000" },
|
||||
{ token: "comment", foreground: "008000" },
|
||||
{ token: "number", foreground: "098658" },
|
||||
{ token: "number.hex", foreground: "3030c0" },
|
||||
{ token: "regexp", foreground: "800000" },
|
||||
{ token: "annotation", foreground: "808080" },
|
||||
{ token: "type", foreground: "008080" },
|
||||
|
||||
{ token: "delimiter", foreground: "000000" },
|
||||
{ token: "delimiter.html", foreground: "383838" },
|
||||
{ token: "delimiter.xml", foreground: "0000FF" },
|
||||
|
||||
{ token: "tag", foreground: "800000" },
|
||||
{ token: "tag.id.pug", foreground: "4F76AC" },
|
||||
{ token: "tag.class.pug", foreground: "4F76AC" },
|
||||
{ token: "meta.scss", foreground: "800000" },
|
||||
{ token: "metatag", foreground: "e00000" },
|
||||
{ token: "metatag.content.html", foreground: "FF0000" },
|
||||
{ token: "metatag.html", foreground: "808080" },
|
||||
{ token: "metatag.xml", foreground: "808080" },
|
||||
{ token: "metatag.php", fontStyle: "bold" },
|
||||
|
||||
{ token: "key", foreground: "863B00" },
|
||||
{ token: "string.key.json", foreground: "A31515" },
|
||||
{ token: "string.value.json", foreground: "0451A5" },
|
||||
|
||||
{ token: "attribute.name", foreground: "FF0000" },
|
||||
{ token: "attribute.value", foreground: "0451A5" },
|
||||
{ token: "attribute.value.number", foreground: "098658" },
|
||||
{ token: "attribute.value.unit", foreground: "098658" },
|
||||
{ token: "attribute.value.html", foreground: "0000FF" },
|
||||
{ token: "attribute.value.xml", foreground: "0000FF" },
|
||||
|
||||
{ token: "string", foreground: "A31515" },
|
||||
{ token: "string.html", foreground: "0000FF" },
|
||||
{ token: "string.sql", foreground: "FF0000" },
|
||||
{ token: "string.yaml", foreground: "0451A5" },
|
||||
|
||||
{ token: "keyword", foreground: "0000FF" },
|
||||
{ token: "keyword.json", foreground: "0451A5" },
|
||||
{ token: "keyword.flow", foreground: "AF00DB" },
|
||||
{ token: "keyword.flow.scss", foreground: "0000FF" },
|
||||
|
||||
{ token: "operator.scss", foreground: "666666" },
|
||||
{ token: "operator.sql", foreground: "778899" },
|
||||
{ token: "operator.swift", foreground: "666666" },
|
||||
{ token: "predefined.sql", foreground: "C700C7" },
|
||||
],
|
||||
colors: {
|
||||
// surface
|
||||
"editor.background": "#f7f5ff",
|
||||
"minimap.background": "#f7f5ff",
|
||||
"editor.foreground": "#000000",
|
||||
"editor.inactiveSelectionBackground": "#E5EBF1",
|
||||
"editorIndentGuide.background1": "#D3D3D3",
|
||||
"editorIndentGuide.activeBackground1": "#939393",
|
||||
"editor.selectionHighlightBackground": "#ADD6FF4D",
|
||||
},
|
||||
};
|
||||
|
||||
editor.defineTheme("dyad-light", customLight);
|
||||
|
||||
export const customDark: editor.IStandaloneThemeData = {
|
||||
base: "vs-dark",
|
||||
inherit: false,
|
||||
rules: [
|
||||
{ token: "", foreground: "D4D4D4", background: "1E1E1E" },
|
||||
{ token: "invalid", foreground: "f44747" },
|
||||
{ token: "emphasis", fontStyle: "italic" },
|
||||
{ token: "strong", fontStyle: "bold" },
|
||||
|
||||
{ token: "variable", foreground: "74B0DF" },
|
||||
{ token: "variable.predefined", foreground: "4864AA" },
|
||||
{ token: "variable.parameter", foreground: "9CDCFE" },
|
||||
{ token: "constant", foreground: "569CD6" },
|
||||
{ token: "comment", foreground: "608B4E" },
|
||||
{ token: "number", foreground: "B5CEA8" },
|
||||
{ token: "number.hex", foreground: "5BB498" },
|
||||
{ token: "regexp", foreground: "B46695" },
|
||||
{ token: "annotation", foreground: "cc6666" },
|
||||
{ token: "type", foreground: "3DC9B0" },
|
||||
|
||||
{ token: "delimiter", foreground: "DCDCDC" },
|
||||
{ token: "delimiter.html", foreground: "808080" },
|
||||
{ token: "delimiter.xml", foreground: "808080" },
|
||||
|
||||
{ token: "tag", foreground: "569CD6" },
|
||||
{ token: "tag.id.pug", foreground: "4F76AC" },
|
||||
{ token: "tag.class.pug", foreground: "4F76AC" },
|
||||
{ token: "meta.scss", foreground: "A79873" },
|
||||
{ token: "meta.tag", foreground: "CE9178" },
|
||||
{ token: "metatag", foreground: "DD6A6F" },
|
||||
{ token: "metatag.content.html", foreground: "9CDCFE" },
|
||||
{ token: "metatag.html", foreground: "569CD6" },
|
||||
{ token: "metatag.xml", foreground: "569CD6" },
|
||||
{ token: "metatag.php", fontStyle: "bold" },
|
||||
|
||||
{ token: "key", foreground: "9CDCFE" },
|
||||
{ token: "string.key.json", foreground: "9CDCFE" },
|
||||
{ token: "string.value.json", foreground: "CE9178" },
|
||||
|
||||
{ token: "attribute.name", foreground: "9CDCFE" },
|
||||
{ token: "attribute.value", foreground: "CE9178" },
|
||||
{ token: "attribute.value.number.css", foreground: "B5CEA8" },
|
||||
{ token: "attribute.value.unit.css", foreground: "B5CEA8" },
|
||||
{ token: "attribute.value.hex.css", foreground: "D4D4D4" },
|
||||
|
||||
{ token: "string", foreground: "CE9178" },
|
||||
{ token: "string.sql", foreground: "FF0000" },
|
||||
|
||||
{ token: "keyword", foreground: "569CD6" },
|
||||
{ token: "keyword.flow", foreground: "C586C0" },
|
||||
{ token: "keyword.json", foreground: "CE9178" },
|
||||
{ token: "keyword.flow.scss", foreground: "569CD6" },
|
||||
|
||||
{ token: "operator.scss", foreground: "909090" },
|
||||
{ token: "operator.sql", foreground: "778899" },
|
||||
{ token: "operator.swift", foreground: "909090" },
|
||||
{ token: "predefined.sql", foreground: "FF00FF" },
|
||||
],
|
||||
colors: {
|
||||
// surface
|
||||
"editor.background": "#131316",
|
||||
"minimap.background": "#131316",
|
||||
"editor.foreground": "#D4D4D4",
|
||||
"editor.inactiveSelectionBackground": "#3A3D41",
|
||||
"editorIndentGuide.background1": "#404040",
|
||||
"editorIndentGuide.activeBackground1": "#707070",
|
||||
"editor.selectionHighlightBackground": "#ADD6FF26",
|
||||
},
|
||||
};
|
||||
|
||||
editor.defineTheme("dyad-dark", customDark);
|
||||
|
||||
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
|
||||
jsx: monaco.languages.typescript.JsxEmit.React, // Enable JSX
|
||||
});
|
||||
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
|
||||
// Too noisy because we don't have the full TS environment.
|
||||
noSemanticValidation: true,
|
||||
});
|
||||
1
src/components/chat/stateTypes.ts
Normal file
1
src/components/chat/stateTypes.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type CustomTagState = "pending" | "finished" | "aborted";
|
||||
30
src/components/chat/types.d.ts
vendored
Normal file
30
src/components/chat/types.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { Components as ReactMarkdownComponents } from "react-markdown";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Extend the ReactMarkdown Components type to include our custom components
|
||||
declare module "react-markdown" {
|
||||
interface Components extends ReactMarkdownComponents {
|
||||
"dyad-write"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
description?: string;
|
||||
}) => JSX.Element;
|
||||
"dyad-rename"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}) => JSX.Element;
|
||||
"dyad-delete"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
path?: string;
|
||||
}) => JSX.Element;
|
||||
"dyad-add-dependency"?: (props: {
|
||||
children?: ReactNode;
|
||||
node?: any;
|
||||
package?: string;
|
||||
}) => JSX.Element;
|
||||
}
|
||||
}
|
||||
79
src/components/preview_panel/CodeView.tsx
Normal file
79
src/components/preview_panel/CodeView.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { FileTree } from "./FileTree";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectedFileAtom } from "@/atoms/viewAtoms";
|
||||
|
||||
interface App {
|
||||
id?: number;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
export interface CodeViewProps {
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
app: App | null;
|
||||
}
|
||||
|
||||
// Code view component that displays app files or status messages
|
||||
export const CodeView = ({ loading, error, app }: CodeViewProps) => {
|
||||
const selectedFile = useAtomValue(selectedFileAtom);
|
||||
const { refreshApp } = useLoadApp(app?.id ?? null);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-4">Loading files...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-4 text-red-500">
|
||||
Error loading files: {error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500">No app selected</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (app.files && app.files.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center p-2 border-b space-x-2">
|
||||
<button
|
||||
onClick={refreshApp}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading || !app.id}
|
||||
title="Refresh Files"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<div className="text-sm text-gray-500">{app.files.length} files</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="w-1/3 overflow-auto border-r">
|
||||
<FileTree files={app.files} />
|
||||
</div>
|
||||
<div className="w-2/3">
|
||||
{selectedFile ? (
|
||||
<FileEditor appId={app.id ?? null} filePath={selectedFile.path} />
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
Select a file to view
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="text-center py-4 text-gray-500">No files found</div>;
|
||||
};
|
||||
14
src/components/preview_panel/Console.tsx
Normal file
14
src/components/preview_panel/Console.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { appOutputAtom } from "@/atoms/appAtoms";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
// Console component
|
||||
export const Console = () => {
|
||||
const appOutput = useAtomValue(appOutputAtom);
|
||||
return (
|
||||
<div className="font-mono text-xs px-4 h-full overflow-auto">
|
||||
{appOutput.map((output, index) => (
|
||||
<div key={index}>{output.message}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
205
src/components/preview_panel/FileEditor.tsx
Normal file
205
src/components/preview_panel/FileEditor.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import Editor, { OnMount } from "@monaco-editor/react";
|
||||
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { ChevronRight, Circle } from "lucide-react";
|
||||
import "@/components/chat/monaco";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
interface FileEditorProps {
|
||||
appId: number | null;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
path: string;
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
const Breadcrumb: React.FC<BreadcrumbProps> = ({ path, hasUnsavedChanges }) => {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
<div className="flex items-center gap-1 overflow-hidden min-w-0">
|
||||
{segments.map((segment, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-gray-400 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer truncate">
|
||||
{segment}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-2">
|
||||
{hasUnsavedChanges && (
|
||||
<Circle
|
||||
size={8}
|
||||
fill="currentColor"
|
||||
className="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
|
||||
const { content, loading, error } = useLoadAppFile(appId, filePath);
|
||||
const { theme } = useTheme();
|
||||
const [value, setValue] = useState<string | undefined>(undefined);
|
||||
const [displayUnsavedChanges, setDisplayUnsavedChanges] = useState(false);
|
||||
|
||||
// Use refs for values that need to be current in event handlers
|
||||
const originalValueRef = useRef<string | undefined>(undefined);
|
||||
const editorRef = useRef<any>(null);
|
||||
const isSavingRef = useRef<boolean>(false);
|
||||
const needsSaveRef = useRef<boolean>(false);
|
||||
const currentValueRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Update state when content loads
|
||||
useEffect(() => {
|
||||
if (content !== null) {
|
||||
setValue(content);
|
||||
originalValueRef.current = content;
|
||||
currentValueRef.current = content;
|
||||
needsSaveRef.current = false;
|
||||
setDisplayUnsavedChanges(false);
|
||||
}
|
||||
}, [content, filePath]);
|
||||
|
||||
// Sync the UI with the needsSave ref
|
||||
useEffect(() => {
|
||||
setDisplayUnsavedChanges(needsSaveRef.current);
|
||||
}, [needsSaveRef.current]);
|
||||
|
||||
// Determine if dark mode based on theme
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const editorTheme = isDarkMode ? "dyad-dark" : "dyad-light";
|
||||
|
||||
// Handle editor mount
|
||||
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// Listen for model content change events
|
||||
editor.onDidBlurEditorText(() => {
|
||||
console.log("Editor text blurred, checking if save needed");
|
||||
if (needsSaveRef.current) {
|
||||
saveFile();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Handle content change
|
||||
const handleEditorChange = (newValue: string | undefined) => {
|
||||
setValue(newValue);
|
||||
currentValueRef.current = newValue;
|
||||
|
||||
const hasChanged = newValue !== originalValueRef.current;
|
||||
needsSaveRef.current = hasChanged;
|
||||
setDisplayUnsavedChanges(hasChanged);
|
||||
};
|
||||
|
||||
// Save the file
|
||||
const saveFile = async () => {
|
||||
if (
|
||||
!appId ||
|
||||
!currentValueRef.current ||
|
||||
!needsSaveRef.current ||
|
||||
isSavingRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
isSavingRef.current = true;
|
||||
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
await ipcClient.editAppFile(appId, filePath, currentValueRef.current);
|
||||
|
||||
originalValueRef.current = currentValueRef.current;
|
||||
needsSaveRef.current = false;
|
||||
setDisplayUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Error saving file:", error);
|
||||
// Could add error notification here
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine language based on file extension
|
||||
const getLanguage = (filePath: string) => {
|
||||
const extension = filePath.split(".").pop()?.toLowerCase() || "";
|
||||
const languageMap: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
html: "html",
|
||||
css: "css",
|
||||
json: "json",
|
||||
md: "markdown",
|
||||
py: "python",
|
||||
java: "java",
|
||||
c: "c",
|
||||
cpp: "cpp",
|
||||
cs: "csharp",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
rb: "ruby",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
return languageMap[extension] || "plaintext";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4">Loading file content...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-4 text-red-500">Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return <div className="p-4 text-gray-500">No content available</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Breadcrumb path={filePath} hasUnsavedChanges={displayUnsavedChanges} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage={getLanguage(filePath)}
|
||||
value={value}
|
||||
theme={editorTheme}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
minimap: { enabled: true },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
lineNumbers: "on",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
src/components/preview_panel/FileTree.tsx
Normal file
127
src/components/preview_panel/FileTree.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { Folder, FolderOpen } from "lucide-react";
|
||||
import { selectedFileAtom } from "@/atoms/viewAtoms";
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
interface FileTreeProps {
|
||||
files: string[];
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
// Convert flat file list to tree structure
|
||||
const buildFileTree = (files: string[]): TreeNode[] => {
|
||||
const root: TreeNode[] = [];
|
||||
|
||||
files.forEach((path) => {
|
||||
const parts = path.split("/");
|
||||
let currentLevel = root;
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const isLastPart = index === parts.length - 1;
|
||||
const currentPath = parts.slice(0, index + 1).join("/");
|
||||
|
||||
// Check if this node already exists at the current level
|
||||
const existingNode = currentLevel.find((node) => node.name === part);
|
||||
|
||||
if (existingNode) {
|
||||
// If we found the node, just drill down to its children for the next level
|
||||
currentLevel = existingNode.children;
|
||||
} else {
|
||||
// Create a new node
|
||||
const newNode: TreeNode = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
isDirectory: !isLastPart,
|
||||
children: [],
|
||||
};
|
||||
|
||||
currentLevel.push(newNode);
|
||||
currentLevel = newNode.children;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return root;
|
||||
};
|
||||
|
||||
// File tree component
|
||||
export const FileTree = ({ files }: FileTreeProps) => {
|
||||
const treeData = buildFileTree(files);
|
||||
|
||||
return (
|
||||
<div className="file-tree mt-2">
|
||||
<TreeNodes nodes={treeData} level={0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TreeNodesProps {
|
||||
nodes: TreeNode[];
|
||||
level: number;
|
||||
}
|
||||
|
||||
// Sort nodes to show directories first
|
||||
const sortNodes = (nodes: TreeNode[]): TreeNode[] => {
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (a.isDirectory === b.isDirectory) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return a.isDirectory ? -1 : 1;
|
||||
});
|
||||
};
|
||||
|
||||
// Tree nodes component
|
||||
const TreeNodes = ({ nodes, level }: TreeNodesProps) => (
|
||||
<ul className="ml-4">
|
||||
{sortNodes(nodes).map((node, index) => (
|
||||
<TreeNode key={index} node={node} level={level} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: TreeNode;
|
||||
level: number;
|
||||
}
|
||||
|
||||
// Individual tree node component
|
||||
const TreeNode = ({ node, level }: TreeNodeProps) => {
|
||||
const [expanded, setExpanded] = React.useState(level < 2);
|
||||
const setSelectedFile = useSetAtom(selectedFileAtom);
|
||||
|
||||
const handleClick = () => {
|
||||
if (node.isDirectory) {
|
||||
setExpanded(!expanded);
|
||||
} else {
|
||||
setSelectedFile({
|
||||
path: node.path,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="py-0.5">
|
||||
<div
|
||||
className="flex items-center hover:bg-(--sidebar) rounded cursor-pointer px-1.5 py-0.5 text-sm"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{node.isDirectory && (
|
||||
<span className="mr-1 text-gray-500">
|
||||
{expanded ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
</span>
|
||||
)}
|
||||
<span>{node.name}</span>
|
||||
</div>
|
||||
|
||||
{node.isDirectory && expanded && node.children.length > 0 && (
|
||||
<TreeNodes nodes={node.children} level={level + 1} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
414
src/components/preview_panel/PreviewIframe.tsx
Normal file
414
src/components/preview_panel/PreviewIframe.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { selectedAppIdAtom, appUrlAtom, appOutputAtom } from "@/atoms/appAtoms";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
Loader2,
|
||||
X,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Lightbulb,
|
||||
} from "lucide-react";
|
||||
import { chatInputValueAtom } from "@/atoms/chatAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: string | null;
|
||||
onDismiss: () => void;
|
||||
onAIFix: () => void;
|
||||
}
|
||||
|
||||
const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute top-2 left-2 right-2 z-10 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-md shadow-sm p-2">
|
||||
{/* Close button in top left */}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="absolute top-1 left-1 p-1 hover:bg-red-100 dark:hover:bg-red-900/50 rounded"
|
||||
>
|
||||
<X size={14} className="text-red-500 dark:text-red-400" />
|
||||
</button>
|
||||
|
||||
{/* Error message in the middle */}
|
||||
<div className="px-6 py-1 text-sm">
|
||||
<div className="text-red-700 dark:text-red-300 text-wrap">{error}</div>
|
||||
</div>
|
||||
|
||||
{/* Tip message */}
|
||||
<div className="mt-2 px-6">
|
||||
<div className="relative p-2 bg-red-100 dark:bg-red-900/50 rounded-sm flex gap-1 items-center">
|
||||
<div>
|
||||
<Lightbulb size={16} className=" text-red-800 dark:text-red-300" />
|
||||
</div>
|
||||
<span className="text-sm text-red-700 dark:text-red-400">
|
||||
<span className="font-medium">Tip: </span>Check if refreshing the
|
||||
page or restarting the app fixes the error.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Fix button at the bottom */}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
onClick={onAIFix}
|
||||
className="cursor-pointer flex items-center space-x-1 px-2 py-0.5 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Fix error with AI</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Preview iframe component
|
||||
export const PreviewIframe = ({
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}) => {
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const { appUrl } = useAtomValue(appUrlAtom);
|
||||
const setAppOutput = useSetAtom(appOutputAtom);
|
||||
const { app } = useLoadApp(selectedAppId);
|
||||
|
||||
// State to trigger iframe reload
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [iframeError, setIframeError] = useState<string | null>(null);
|
||||
const [showError, setShowError] = useState(true);
|
||||
const setInputValue = useSetAtom(chatInputValueAtom);
|
||||
const [availableRoutes, setAvailableRoutes] = useState<
|
||||
Array<{ path: string; label: string }>
|
||||
>([]);
|
||||
|
||||
// Load router related files to extract routes
|
||||
const { content: routerContent } = useLoadAppFile(
|
||||
selectedAppId,
|
||||
"src/App.tsx"
|
||||
);
|
||||
|
||||
// Effect to parse routes from the router file
|
||||
useEffect(() => {
|
||||
if (routerContent) {
|
||||
console.log("routerContent", routerContent);
|
||||
try {
|
||||
const routes: Array<{ path: string; label: string }> = [];
|
||||
|
||||
// Extract route imports and paths using regex for React Router syntax
|
||||
// Match <Route path="/path">
|
||||
const routePathsRegex = /<Route\s+(?:[^>]*\s+)?path=["']([^"']+)["']/g;
|
||||
let match;
|
||||
|
||||
// Find all route paths in the router content
|
||||
while ((match = routePathsRegex.exec(routerContent)) !== null) {
|
||||
const path = match[1];
|
||||
// Create a readable label from the path
|
||||
const label =
|
||||
path === "/"
|
||||
? "Home"
|
||||
: path
|
||||
.split("/")
|
||||
.filter((segment) => segment && !segment.startsWith(":"))
|
||||
.pop()
|
||||
?.replace(/[-_]/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase()) || path;
|
||||
|
||||
if (!routes.some((r) => r.path === path)) {
|
||||
routes.push({ path, label });
|
||||
}
|
||||
}
|
||||
|
||||
setAvailableRoutes(routes);
|
||||
} catch (e) {
|
||||
console.error("Error parsing router file:", e);
|
||||
}
|
||||
}
|
||||
}, [routerContent]);
|
||||
|
||||
// Navigation state
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [canGoForward, setCanGoForward] = useState(false);
|
||||
const [navigationHistory, setNavigationHistory] = useState<string[]>([]);
|
||||
const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Add message listener for iframe errors and navigation events
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// Only handle messages from our iframe
|
||||
if (event.source !== iframeRef.current?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, payload } = event.data;
|
||||
|
||||
if (type === "window-error") {
|
||||
const errorMessage = `Error in ${payload.filename} (line ${payload.lineno}, col ${payload.colno}): ${payload.message}`;
|
||||
console.error("Iframe error:", errorMessage);
|
||||
setIframeError(errorMessage);
|
||||
setAppOutput((prev) => [
|
||||
...prev,
|
||||
{
|
||||
message: `Iframe error: ${errorMessage}`,
|
||||
type: "client-error",
|
||||
appId: selectedAppId!,
|
||||
},
|
||||
]);
|
||||
} else if (type === "unhandled-rejection") {
|
||||
const errorMessage = `Unhandled Promise Rejection: ${payload.reason}`;
|
||||
console.error("Iframe unhandled rejection:", errorMessage);
|
||||
setIframeError(errorMessage);
|
||||
setAppOutput((prev) => [
|
||||
...prev,
|
||||
{
|
||||
message: `Iframe unhandled rejection: ${errorMessage}`,
|
||||
type: "client-error",
|
||||
appId: selectedAppId!,
|
||||
},
|
||||
]);
|
||||
} else if (type === "pushState" || type === "replaceState") {
|
||||
console.debug(`Navigation event: ${type}`, payload);
|
||||
|
||||
// Update navigation history based on the type of state change
|
||||
if (type === "pushState") {
|
||||
// For pushState, we trim any forward history and add the new URL
|
||||
const newHistory = [
|
||||
...navigationHistory.slice(0, currentHistoryPosition + 1),
|
||||
payload.newUrl,
|
||||
];
|
||||
setNavigationHistory(newHistory);
|
||||
setCurrentHistoryPosition(newHistory.length - 1);
|
||||
} else if (type === "replaceState") {
|
||||
// For replaceState, we replace the current URL
|
||||
const newHistory = [...navigationHistory];
|
||||
newHistory[currentHistoryPosition] = payload.newUrl;
|
||||
setNavigationHistory(newHistory);
|
||||
}
|
||||
|
||||
// Update navigation buttons state
|
||||
setCanGoBack(currentHistoryPosition > 0);
|
||||
setCanGoForward(currentHistoryPosition < navigationHistory.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [navigationHistory, currentHistoryPosition, selectedAppId]);
|
||||
|
||||
// Initialize navigation history when iframe loads
|
||||
useEffect(() => {
|
||||
if (appUrl) {
|
||||
setNavigationHistory([appUrl]);
|
||||
setCurrentHistoryPosition(0);
|
||||
setCanGoBack(false);
|
||||
setCanGoForward(false);
|
||||
}
|
||||
}, [appUrl]);
|
||||
|
||||
// Function to navigate back
|
||||
const handleNavigateBack = () => {
|
||||
if (canGoBack && iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "navigate",
|
||||
payload: { direction: "backward" },
|
||||
},
|
||||
"*"
|
||||
);
|
||||
|
||||
// Update our local state
|
||||
setCurrentHistoryPosition((prev) => prev - 1);
|
||||
setCanGoBack(currentHistoryPosition - 1 > 0);
|
||||
setCanGoForward(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to navigate forward
|
||||
const handleNavigateForward = () => {
|
||||
if (canGoForward && iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "navigate",
|
||||
payload: { direction: "forward" },
|
||||
},
|
||||
"*"
|
||||
);
|
||||
|
||||
// Update our local state
|
||||
setCurrentHistoryPosition((prev) => prev + 1);
|
||||
setCanGoBack(true);
|
||||
setCanGoForward(
|
||||
currentHistoryPosition + 1 < navigationHistory.length - 1
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle reload
|
||||
const handleReload = () => {
|
||||
setReloadKey((prevKey) => prevKey + 1);
|
||||
// Optionally, add logic here if you need to explicitly stop/start the app again
|
||||
// For now, just changing the key should remount the iframe
|
||||
console.debug("Reloading iframe preview for app", selectedAppId);
|
||||
};
|
||||
|
||||
// Function to navigate to a specific route
|
||||
const navigateToRoute = (path: string) => {
|
||||
if (iframeRef.current?.contentWindow && appUrl) {
|
||||
// Create the full URL by combining the base URL with the path
|
||||
const baseUrl = new URL(appUrl).origin;
|
||||
const newUrl = `${baseUrl}${path}`;
|
||||
|
||||
// Navigate to the URL
|
||||
iframeRef.current.contentWindow.location.href = newUrl;
|
||||
|
||||
// Update navigation history
|
||||
const newHistory = [
|
||||
...navigationHistory.slice(0, currentHistoryPosition + 1),
|
||||
newUrl,
|
||||
];
|
||||
setNavigationHistory(newHistory);
|
||||
setCurrentHistoryPosition(newHistory.length - 1);
|
||||
setCanGoBack(true);
|
||||
setCanGoForward(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Display loading state
|
||||
if (loading) {
|
||||
return <div className="p-4 dark:text-gray-300">Loading app preview...</div>;
|
||||
}
|
||||
|
||||
// Display message if no app is selected
|
||||
if (selectedAppId === null) {
|
||||
return (
|
||||
<div className="p-4 text-gray-500 dark:text-gray-400">
|
||||
Select an app to see the preview.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Browser-style header */}
|
||||
<div className="flex items-center p-2 border-b space-x-2 ">
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoBack || loading || !selectedAppId}
|
||||
onClick={handleNavigateBack}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoForward || loading || !selectedAppId}
|
||||
onClick={handleNavigateForward}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReload}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={loading || !selectedAppId}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
|
||||
<div className="relative flex-grow">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm text-gray-700 dark:text-gray-200 cursor-pointer w-full">
|
||||
<span>
|
||||
{navigationHistory[currentHistoryPosition]
|
||||
? new URL(navigationHistory[currentHistoryPosition])
|
||||
.pathname
|
||||
: "/"}
|
||||
</span>
|
||||
<ChevronDown size={14} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{availableRoutes.length > 0 ? (
|
||||
availableRoutes.map((route) => (
|
||||
<DropdownMenuItem
|
||||
key={route.path}
|
||||
onClick={() => navigateToRoute(route.path)}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<span>{route.label}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{route.path}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>Loading routes...</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (appUrl) {
|
||||
IpcClient.getInstance().openExternalUrl(appUrl);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-grow ">
|
||||
<ErrorBanner
|
||||
error={showError ? error?.message || iframeError : null}
|
||||
onDismiss={() => setShowError(false)}
|
||||
onAIFix={() => {
|
||||
setInputValue(`Fix the error in ${error?.message || iframeError}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
{!appUrl ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Starting up your app...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
key={reloadKey}
|
||||
title={`Preview for App ${selectedAppId}`}
|
||||
className="w-full h-full border-none bg-white dark:bg-gray-950"
|
||||
src={appUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
192
src/components/preview_panel/PreviewPanel.tsx
Normal file
192
src/components/preview_panel/PreviewPanel.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { previewModeAtom, selectedAppIdAtom } from "../../atoms/appAtoms";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { CodeView } from "./CodeView";
|
||||
import { PreviewIframe } from "./PreviewIframe";
|
||||
import {
|
||||
Eye,
|
||||
Code,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Logs,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { Console } from "./Console";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
|
||||
type PreviewMode = "preview" | "code";
|
||||
|
||||
interface PreviewHeaderProps {
|
||||
previewMode: PreviewMode;
|
||||
setPreviewMode: (mode: PreviewMode) => void;
|
||||
onRestart: () => void;
|
||||
}
|
||||
|
||||
// Preview Header component with preview mode toggle
|
||||
const PreviewHeader = ({
|
||||
previewMode,
|
||||
setPreviewMode,
|
||||
onRestart,
|
||||
}: PreviewHeaderProps) => (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
||||
<div className="relative flex space-x-2 bg-[var(--background-darkest)] rounded-md p-0.5">
|
||||
<button
|
||||
className="relative flex items-center space-x-1 px-3 py-1 rounded-md text-sm z-10"
|
||||
onClick={() => setPreviewMode("preview")}
|
||||
>
|
||||
{previewMode === "preview" && (
|
||||
<motion.div
|
||||
layoutId="activeIndicator"
|
||||
className="absolute inset-0 bg-(--background-lightest) shadow rounded-md -z-1"
|
||||
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
<Eye size={16} />
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
<button
|
||||
className="relative flex items-center space-x-1 px-3 py-1 rounded-md text-sm z-10"
|
||||
onClick={() => setPreviewMode("code")}
|
||||
>
|
||||
{previewMode === "code" && (
|
||||
<motion.div
|
||||
layoutId="activeIndicator"
|
||||
className="absolute inset-0 bg-(--background-lightest) shadow rounded-md -z-1"
|
||||
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
<Code size={16} />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="flex items-center space-x-1 px-3 py-1 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
|
||||
title="Restart App"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Console header component
|
||||
const ConsoleHeader = ({
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) => (
|
||||
<div
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 px-4 py-1.5 border-t border-border cursor-pointer hover:bg-[var(--background-darkest)] transition-colors"
|
||||
>
|
||||
<Logs size={16} />
|
||||
<span className="text-sm font-medium">System Messages</span>
|
||||
<div className="flex-1" />
|
||||
{isOpen ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Main PreviewPanel component
|
||||
export function PreviewPanel() {
|
||||
const [previewMode, setPreviewMode] = useAtom(previewModeAtom);
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const [isConsoleOpen, setIsConsoleOpen] = useState(false);
|
||||
const { runApp, stopApp, restartApp, error, loading, app } = useRunApp();
|
||||
const runningAppIdRef = useRef<number | null>(null);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
if (selectedAppId !== null) {
|
||||
restartApp(selectedAppId);
|
||||
}
|
||||
}, [selectedAppId, restartApp]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousAppId = runningAppIdRef.current;
|
||||
|
||||
// Check if the selected app ID has changed
|
||||
if (selectedAppId !== previousAppId) {
|
||||
// Stop the previously running app, if any
|
||||
if (previousAppId !== null) {
|
||||
console.debug("Stopping previous app", previousAppId);
|
||||
stopApp(previousAppId);
|
||||
// We don't necessarily nullify the ref here immediately,
|
||||
// let the start of the next app update it or unmount handle it.
|
||||
}
|
||||
|
||||
// Start the new app if an ID is selected
|
||||
if (selectedAppId !== null) {
|
||||
console.debug("Starting new app", selectedAppId);
|
||||
runApp(selectedAppId); // Consider adding error handling for the promise if needed
|
||||
runningAppIdRef.current = selectedAppId; // Update ref to the new running app ID
|
||||
} else {
|
||||
// If selectedAppId is null, ensure no app is marked as running
|
||||
runningAppIdRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function: This runs when the component unmounts OR before the effect runs again.
|
||||
// We only want to stop the app on actual unmount. The logic above handles stopping
|
||||
// when the appId changes. So, we capture the running appId at the time the effect renders.
|
||||
const appToStopOnUnmount = runningAppIdRef.current;
|
||||
return () => {
|
||||
if (appToStopOnUnmount !== null) {
|
||||
const currentRunningApp = runningAppIdRef.current;
|
||||
if (currentRunningApp !== null) {
|
||||
console.debug(
|
||||
"Component unmounting or selectedAppId changing, stopping app",
|
||||
currentRunningApp
|
||||
);
|
||||
stopApp(currentRunningApp);
|
||||
runningAppIdRef.current = null; // Clear ref on stop
|
||||
}
|
||||
}
|
||||
};
|
||||
// Dependencies: run effect when selectedAppId changes.
|
||||
// runApp/stopApp are stable due to useCallback.
|
||||
}, [selectedAppId, runApp, stopApp]);
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PreviewHeader
|
||||
previewMode={previewMode}
|
||||
setPreviewMode={setPreviewMode}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel id="content" minSize={30}>
|
||||
<div className="h-full overflow-y-auto">
|
||||
{previewMode === "preview" ? (
|
||||
<PreviewIframe loading={loading} error={error} />
|
||||
) : (
|
||||
<CodeView loading={loading} error={error} app={app} />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
{isConsoleOpen && (
|
||||
<>
|
||||
<PanelResizeHandle className="h-1 bg-border hover:bg-gray-400 transition-colors cursor-row-resize" />
|
||||
<Panel id="console" minSize={10} defaultSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<ConsoleHeader
|
||||
isOpen={true}
|
||||
onToggle={() => setIsConsoleOpen(false)}
|
||||
/>
|
||||
<Console />
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</PanelGroup>
|
||||
</div>
|
||||
{!isConsoleOpen && (
|
||||
<ConsoleHeader isOpen={false} onToggle={() => setIsConsoleOpen(true)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
357
src/components/settings/ProviderSettingsPage.tsx
Normal file
357
src/components/settings/ProviderSettingsPage.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Info,
|
||||
Circle,
|
||||
Settings as SettingsIcon,
|
||||
GiftIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { PROVIDER_TO_ENV_VAR, PROVIDERS } from "@/constants/models";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
interface ProviderSettingsPageProps {
|
||||
provider: string;
|
||||
}
|
||||
|
||||
// Helper function to mask ENV API keys (still needed for env vars)
|
||||
const maskEnvApiKey = (key: string | undefined): string => {
|
||||
if (!key) return "Not Set";
|
||||
if (key.length < 8) return "****";
|
||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
|
||||
export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
|
||||
const {
|
||||
settings,
|
||||
envVars,
|
||||
loading: settingsLoading,
|
||||
error: settingsError,
|
||||
updateSettings,
|
||||
} = useSettings();
|
||||
|
||||
const [apiKeyInput, setApiKeyInput] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Find provider details
|
||||
const providerInfo = PROVIDERS[provider as keyof typeof PROVIDERS];
|
||||
const providerDisplayName =
|
||||
providerInfo?.displayName ||
|
||||
provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
const providerWebsiteUrl = providerInfo?.websiteUrl;
|
||||
const hasFreeTier = providerInfo?.hasFreeTier;
|
||||
|
||||
const envVarName = PROVIDER_TO_ENV_VAR[provider];
|
||||
const envApiKey = envVars[envVarName];
|
||||
const userApiKey = settings?.providerSettings?.[provider]?.apiKey;
|
||||
|
||||
// --- Configuration Logic --- Updated Priority ---
|
||||
const isValidUserKey =
|
||||
!!userApiKey &&
|
||||
!userApiKey.startsWith("Invalid Key") &&
|
||||
userApiKey !== "Not Set";
|
||||
const hasEnvKey = !!envApiKey;
|
||||
|
||||
const isConfigured = isValidUserKey || hasEnvKey; // Configured if either is set
|
||||
// Settings key takes precedence if it's valid
|
||||
const activeKeySource = isValidUserKey
|
||||
? "settings"
|
||||
: hasEnvKey
|
||||
? "env"
|
||||
: "none";
|
||||
|
||||
// --- Accordion Logic ---
|
||||
const defaultAccordionValue = [];
|
||||
if (isValidUserKey || !hasEnvKey) {
|
||||
// If user key is set OR env key is NOT set, open the settings accordion item
|
||||
defaultAccordionValue.push("settings-key");
|
||||
}
|
||||
if (hasEnvKey) {
|
||||
defaultAccordionValue.push("env-key");
|
||||
}
|
||||
|
||||
// --- Save Handler ---
|
||||
const handleSaveKey = async () => {
|
||||
if (!apiKeyInput) {
|
||||
setSaveError("API Key cannot be empty.");
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await updateSettings({
|
||||
providerSettings: {
|
||||
...settings?.providerSettings,
|
||||
[provider]: {
|
||||
...(settings?.providerSettings?.[provider] || {}),
|
||||
apiKey: apiKeyInput,
|
||||
},
|
||||
},
|
||||
});
|
||||
setApiKeyInput(""); // Clear input on success
|
||||
// Optionally show a success message
|
||||
} catch (error: any) {
|
||||
console.error("Error saving API key:", error);
|
||||
setSaveError(error.message || "Failed to save API key.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Delete Handler ---
|
||||
const handleDeleteKey = async () => {
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await updateSettings({
|
||||
providerSettings: {
|
||||
...settings?.providerSettings,
|
||||
[provider]: {
|
||||
...(settings?.providerSettings?.[provider] || {}),
|
||||
apiKey: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Optionally show a success message
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting API key:", error);
|
||||
setSaveError(error.message || "Failed to delete API key.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Effect to clear input error when input changes
|
||||
useEffect(() => {
|
||||
if (saveError) {
|
||||
setSaveError(null);
|
||||
}
|
||||
}, [apiKeyInput]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<Button
|
||||
onClick={() => router.history.back()}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2 mb-4 bg-(--background-lightest) py-5"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center mb-1">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mr-3">
|
||||
Configure {providerDisplayName}
|
||||
</h1>
|
||||
{settingsLoading ? (
|
||||
<Skeleton className="h-6 w-6 rounded-full" />
|
||||
) : (
|
||||
<Circle
|
||||
className={`h-5 w-5 ${
|
||||
isConfigured
|
||||
? "fill-green-500 text-green-600"
|
||||
: "fill-yellow-400 text-yellow-500"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<span className="ml-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{settingsLoading
|
||||
? "Loading..."
|
||||
: isConfigured
|
||||
? "Setup Complete"
|
||||
: "Not Setup"}
|
||||
</span>
|
||||
</div>
|
||||
{!settingsLoading && hasFreeTier && (
|
||||
<span className="text-blue-600 mt-2 dark:text-blue-400 text-sm font-medium bg-blue-100 dark:bg-blue-900/30 px-2 py-1 rounded-full inline-flex items-center">
|
||||
<GiftIcon className="w-4 h-4 mr-1" />
|
||||
Free tier available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{providerWebsiteUrl && !settingsLoading && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
IpcClient.getInstance().openExternalUrl(providerWebsiteUrl);
|
||||
}}
|
||||
className="mb-4 bg-(--background-lightest) cursor-pointer py-5"
|
||||
variant="outline"
|
||||
>
|
||||
{isConfigured ? (
|
||||
<SettingsIcon className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isConfigured ? "Manage API Keys" : "Setup API Key"}
|
||||
<ExternalLink className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{settingsLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
) : settingsError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error Loading Settings</AlertTitle>
|
||||
<AlertDescription>
|
||||
Could not load configuration data: {settingsError.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full space-y-4"
|
||||
defaultValue={defaultAccordionValue}
|
||||
>
|
||||
<AccordionItem
|
||||
value="settings-key"
|
||||
className="border rounded-lg px-4 bg-(--background-lightest)"
|
||||
>
|
||||
<AccordionTrigger className="text-lg font-medium hover:no-underline cursor-pointer">
|
||||
API Key from Settings
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-4 ">
|
||||
{isValidUserKey && (
|
||||
<Alert variant="default" className="mb-4">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
<AlertTitle className="flex justify-between items-center">
|
||||
<span>Current Key (Settings)</span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDeleteKey}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1 h-7 px-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{isSaving ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="font-mono text-sm">{userApiKey}</p>
|
||||
{activeKeySource === "settings" && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
This key is currently active.
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="apiKeyInput"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{isValidUserKey ? "Update" : "Set"} {providerDisplayName}{" "}
|
||||
API Key
|
||||
</label>
|
||||
<div className="flex items-start space-x-2">
|
||||
<Input
|
||||
type="password"
|
||||
id="apiKeyInput"
|
||||
value={apiKeyInput}
|
||||
onChange={(e) => setApiKeyInput(e.target.value)}
|
||||
placeholder={`Enter new ${providerDisplayName} API Key here`}
|
||||
className={`flex-grow ${
|
||||
saveError ? "border-red-500" : ""
|
||||
}`}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSaveKey}
|
||||
disabled={isSaving || !apiKeyInput}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save Key"}
|
||||
</Button>
|
||||
</div>
|
||||
{saveError && (
|
||||
<p className="text-xs text-red-600">{saveError}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Setting a key here will override the environment variable
|
||||
(if set).
|
||||
</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem
|
||||
value="env-key"
|
||||
className="border rounded-lg px-4 bg-(--background-lightest)"
|
||||
>
|
||||
<AccordionTrigger className="text-lg font-medium hover:no-underline cursor-pointer">
|
||||
API Key from Environment Variable
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-4">
|
||||
{hasEnvKey ? (
|
||||
<Alert variant="default">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
Environment Variable Key ({envVarName})
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="font-mono text-sm">
|
||||
{maskEnvApiKey(envApiKey)}
|
||||
</p>
|
||||
{activeKeySource === "env" && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||
This key is currently active (no settings key set).
|
||||
</p>
|
||||
)}
|
||||
{activeKeySource === "settings" && (
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
|
||||
This key is currently being overridden by the key set
|
||||
in Settings.
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="default">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>Environment Variable Not Set</AlertTitle>
|
||||
<AlertDescription>
|
||||
The{" "}
|
||||
<code className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded text-xs">
|
||||
{envVarName}
|
||||
</code>{" "}
|
||||
environment variable is not set.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
|
||||
This key is set outside the application. If present, it will
|
||||
be used only if no key is configured in the Settings section
|
||||
above. Requires app restart to detect changes.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/ui/accordion.tsx
Normal file
64
src/components/ui/accordion.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
60
src/components/ui/button.tsx
Normal file
60
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import type * 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 shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs 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 shadow-xs 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",
|
||||
sidebar: "h-16 w-16",
|
||||
},
|
||||
},
|
||||
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 };
|
||||
85
src/components/ui/card.tsx
Normal file
85
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
133
src/components/ui/dialog.tsx
Normal file
133
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
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,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
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}
|
||||
<DialogPrimitive.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,
|
||||
}
|
||||
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
29
src/components/ui/popover.tsx
Normal file
29
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent };
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import type * 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-root"
|
||||
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 }
|
||||
137
src/components/ui/sheet.tsx
Normal file
137
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import type * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-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 SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
708
src/components/ui/sidebar.tsx
Normal file
708
src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,708 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { Menu, PanelLeftIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "5rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open]
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
setOpen((open) => !open);
|
||||
}, [setOpen]);
|
||||
|
||||
// Auto-collapse on small screens
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia("(max-width: 480px)");
|
||||
const handleResize = () => {
|
||||
if (mql.matches) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
mql.addEventListener("change", handleResize);
|
||||
handleResize(); // Check initial size
|
||||
|
||||
return () => mql.removeEventListener("change", handleResize);
|
||||
}, [setOpen]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, toggleSidebar]
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"bg-sidebar",
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { state } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width) transition-[left,right,width,transform] duration-200 ease-linear",
|
||||
side === "left"
|
||||
? "left-0 translate-x-0 group-data-[collapsible=offcanvas]:translate-x-[-100%]"
|
||||
: "right-0 translate-x-0 group-data-[collapsible=offcanvas]:translate-x-[100%]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l border-sidebar-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="sidebar"
|
||||
className="cursor-pointer ml-1 hover:bg-sidebar"
|
||||
// className={cn("hidden", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
<span className="sr-only">Toggle Menu</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="center">
|
||||
Toggle Menu
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left][data-state=collapsed]_&]:cursor-e-resize in-data-[side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
// (Only the sidebarMenuButtonVariants constant is updated; the rest of the code remains unchanged)
|
||||
// Updated base classes:
|
||||
// • Changed flex direction to column and centered items.
|
||||
// • Enforced a fixed width (w-20) for consistent space.
|
||||
// • Removed text-left and gap changes to ensure the text label appears below the icon.
|
||||
"peer/menu-button flex flex-col items-center gap-1 w-16 overflow-hidden p-2 text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0 [&>span]:mt-1",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed"}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
61
src/components/ui/tooltip.tsx
Normal file
61
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import type * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user