diff --git a/.prettierrc b/.prettierrc index 83433021c2..7877a5f8e5 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,8 @@ "printWidth": 120, "trailingComma": "none", "endOfLine": "lf", - "bracketSameLine": true + "bracketSameLine": true, + "tailwindStylesheet": "./src/renderer/src/assets/styles/tailwind.css", + "tailwindFunctions": ["clsx"], + "plugins": ["prettier-plugin-tailwindcss"], } diff --git a/components.json b/components.json new file mode 100644 index 0000000000..3d64f95424 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/renderer/src/assets/styles/tailwind.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@renderer/ui/third-party", + "utils": "@renderer/utils", + "ui": "@renderer/ui", + "lib": "@renderer/lib", + "hooks": "@renderer/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7f4a4e3a66..15f7be95ce 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ }, renderer: { plugins: [ + (async () => (await import('@tailwindcss/vite')).default())(), react({ plugins: [ [ diff --git a/eslint.config.mjs b/eslint.config.mjs index 33e6ae8757..0d383eccee 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -62,7 +62,8 @@ export default defineConfig([ '.yarn/**', '.gitignore', 'scripts/cloudflare-worker.js', - 'src/main/integration/nutstore/sso/lib/**' + 'src/main/integration/nutstore/sso/lib/**', + 'src/renderer/src/ui/**' ] } ]) diff --git a/package.json b/package.json index 0536e89407..f5253aa52b 100644 --- a/package.json +++ b/package.json @@ -119,9 +119,18 @@ "@mozilla/readability": "^0.6.0", "@notionhq/client": "^2.2.15", "@playwright/test": "^1.52.0", + "@radix-ui/react-collapsible": "^1.1.10", + "@radix-ui/react-dialog": "^1.1.13", + "@radix-ui/react-dropdown-menu": "^2.1.14", + "@radix-ui/react-separator": "^1.1.6", + "@radix-ui/react-slot": "^1.2.2", + "@radix-ui/react-tabs": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.6", "@reduxjs/toolkit": "^2.2.5", "@shikijs/markdown-it": "^3.4.2", "@swc/plugin-styled-components": "^7.1.5", + "@tailwindcss/vite": "^4.1.5", + "@tavily/core": "patch:@tavily/core@npm%3A0.3.1#~/.yarn/patches/@tavily-core-npm-0.3.1-fe69bf2bea.patch", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -150,6 +159,8 @@ "antd": "^5.22.5", "axios": "^1.7.3", "browser-image-compression": "^2.0.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", "color": "^5.0.0", "dayjs": "^1.11.11", "dexie": "^4.0.8", @@ -173,15 +184,17 @@ "lint-staged": "^15.5.0", "lodash": "^4.17.21", "lru-cache": "^11.1.0", - "lucide-react": "^0.487.0", + "lucide-react": "^0.511.0", "mermaid": "^11.6.0", "mime": "^4.0.4", - "motion": "^12.10.5", + "motion": "^12.12.1", + "next-themes": "^0.4.6", "npx-scope-finder": "^1.2.0", "openai": "patch:openai@npm%3A5.1.0#~/.yarn/patches/openai-npm-5.1.0-0e7b3ccb07.patch", "p-queue": "^8.1.0", "playwright": "^1.52.0", "prettier": "^3.5.3", + "prettier-plugin-tailwindcss": "^0.6.11", "rc-virtual-list": "^3.18.6", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -205,11 +218,16 @@ "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.88.0", "shiki": "^3.4.2", + "sonner": "^2.0.3", "string-width": "^7.2.0", "styled-components": "^6.1.11", + "tailwind-merge": "^3.2.0", + "tailwindcss": "^4.1.5", "tiny-pinyin": "^1.3.2", "tokenx": "^0.4.1", + "tw-animate-css": "^1.2.9", "typescript": "^5.6.2", + "usehooks-ts": "^3.1.1", "uuid": "^10.0.0", "vite": "6.2.6", "vitest": "^3.1.4" diff --git a/src/main/services/mcp/oauth/provider.ts b/src/main/services/mcp/oauth/provider.ts index a2a47fc15e..bc37c952e9 100644 --- a/src/main/services/mcp/oauth/provider.ts +++ b/src/main/services/mcp/oauth/provider.ts @@ -1,8 +1,12 @@ import path from 'node:path' import { getConfigDir } from '@main/utils/file' -import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth' -import { OAuthClientInformation, OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth' +import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { + OAuthClientInformation, + OAuthClientInformationFull, + OAuthTokens +} from '@modelcontextprotocol/sdk/shared/auth.js' import Logger from 'electron-log' import open from 'open' diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 102e61c628..cca1fa1cc3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -13,8 +13,7 @@ import { NotificationProvider } from './context/NotificationProvider' import StyleSheetManager from './context/StyleSheetManager' import { ThemeProvider } from './context/ThemeProvider' import NavigationHandler from './handler/NavigationHandler' -import AgentsPage from './pages/agents/AgentsPage' -import AppsPage from './pages/apps/AppsPage' +import DiscoverPage from './pages/discover' import FilesPage from './pages/files/FilesPage' import HomePage from './pages/home/HomePage' import KnowledgePage from './pages/knowledge/KnowledgePage' @@ -38,14 +37,15 @@ function App(): React.ReactElement { } /> - } /> + {/* } /> */} } /> } /> } /> } /> - } /> + {/* } /> */} } /> } /> + } /> diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index cf1b92dcbb..961a316b4f 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -12,7 +12,7 @@ *::before, *::after { box-sizing: border-box; - margin: 0; + // margin: 0; font-weight: normal; } diff --git a/src/renderer/src/assets/styles/tailwind.css b/src/renderer/src/assets/styles/tailwind.css new file mode 100644 index 0000000000..63ba717604 --- /dev/null +++ b/src/renderer/src/assets/styles/tailwind.css @@ -0,0 +1,146 @@ +@import 'tailwindcss' source('../../../src'); +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +/* 如需自定义: +1. 清晰地组织自定义 CSS 到相应的层中。 +2. 基础样式(如全局重置、链接样式)放入 base 层; +3. 可复用的组件样式(如果仍使用 @apply 或原生 CSS 嵌套创建)放入 components 层; +4. 新的自定义工具类放入 utilities 层。 +*/ + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.21 0.006 285.885); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 0.004 286.32); + --ring: oklch(0.705 0.015 286.067); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.141 0.005 285.823); + --sidebar-primary: oklch(0.21 0.006 285.885); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.967 0.001 286.375); + --sidebar-accent-foreground: oklch(0.21 0.006 285.885); + --sidebar-border: oklch(0.92 0.004 286.32); + --sidebar-ring: oklch(0.705 0.015 286.067); +} + +.dark { + --background: oklch(0.141 0.005 285.823); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.92 0.004 286.32); + --primary-foreground: oklch(0.21 0.006 285.885); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.552 0.016 285.938); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.21 0.006 285.885); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --animate-marquee: marquee var(--duration) infinite linear; + --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; + @keyframes marquee { + from { + transform: translateX(0); + } + to { + transform: translateX(calc(-100% - var(--gap))); + } + } + @keyframes marquee-vertical { + from { + transform: translateY(0); + } + to { + transform: translateY(calc(-100% - var(--gap))); + } + } +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/renderer/src/components/app/MainSidebar.tsx b/src/renderer/src/components/app/MainSidebar.tsx index 50e2e71dcb..79bd2885fb 100644 --- a/src/renderer/src/components/app/MainSidebar.tsx +++ b/src/renderer/src/components/app/MainSidebar.tsx @@ -15,14 +15,13 @@ import { Bot, ChevronDown, ChevronRight, + Compass, FileSearch, Folder, Languages, - LayoutGrid, MessageSquare, Moon, Palette, - Sparkle, SquareTerminal, Sun, SunMoon @@ -98,14 +97,15 @@ const MainSidebar: FC = () => { } const appMenuItems = [ - { icon: , text: t('agents.title'), path: '/agents' }, + // { icon: , text: t('agents.title'), path: '/agents' }, + { icon: , text: t('discover.title'), path: '/discover' }, { icon: , text: t('translate.title'), path: '/translate' }, { icon: , text: t('paintings.title'), path: `/paintings/${defaultPaintingProvider}` }, - { icon: , text: t('minapp.title'), path: '/apps' }, + // { icon: , text: t('minapp.title'), path: '/apps' }, { icon: , text: t('knowledge.title'), path: '/knowledge' }, { icon: , text: t('common.mcp'), path: '/mcp-servers' }, { icon: , text: t('files.title'), path: '/files' } diff --git a/src/renderer/src/components/app/Sidebar.tsx b/src/renderer/src/components/app/Sidebar.tsx index 5e4365c3c9..334ccc8245 100644 --- a/src/renderer/src/components/app/Sidebar.tsx +++ b/src/renderer/src/components/app/Sidebar.tsx @@ -16,6 +16,7 @@ import type { MenuProps } from 'antd' import { Avatar, Dropdown, Tooltip } from 'antd' import { CircleHelp, + Compass, FileSearch, Folder, Languages, @@ -155,7 +156,8 @@ const MainMenus: FC = () => { translate: , minapp: , knowledge: , - files: + files: , + discover: } const pathMap = { @@ -165,7 +167,8 @@ const MainMenus: FC = () => { translate: '/translate', minapp: '/apps', knowledge: '/knowledge', - files: '/files' + files: '/files', + discover: '/discover' } return sidebarIcons.visible.map((icon) => { diff --git a/src/renderer/src/context/ThemeProvider.tsx b/src/renderer/src/context/ThemeProvider.tsx index 85b0dbae86..7b00ee9d98 100644 --- a/src/renderer/src/context/ThemeProvider.tsx +++ b/src/renderer/src/context/ThemeProvider.tsx @@ -38,8 +38,22 @@ export const ThemeProvider: React.FC = ({ children }) => { setSettedTheme(nextTheme || ThemeMode.system) } + const tailwindThemeChange = (theme) => { + const root = window.document.documentElement + root.classList.remove('light', 'dark') + root.classList.add(theme) + } + + useEffect(() => { + window.api?.setTheme(settedTheme || actualTheme) + }, [settedTheme, actualTheme]) + + useEffect(() => { + document.body.setAttribute('theme-mode', settedTheme) + tailwindThemeChange(settedTheme) + }, [settedTheme]) + useEffect(() => { - // Set initial theme and OS attributes on body document.body.setAttribute('os', isMac ? 'mac' : 'windows') document.body.setAttribute('theme-mode', actualTheme) diff --git a/src/renderer/src/entryPoint.tsx b/src/renderer/src/entryPoint.tsx index bf6a3cb6a5..fc5769da91 100644 --- a/src/renderer/src/entryPoint.tsx +++ b/src/renderer/src/entryPoint.tsx @@ -1,5 +1,6 @@ import './assets/styles/index.scss' import '@ant-design/v5-patch-for-react-19' +import './assets/styles/tailwind.css' import { createRoot } from 'react-dom/client' diff --git a/src/renderer/src/hooks/use-mobile.ts b/src/renderer/src/hooks/use-mobile.ts new file mode 100644 index 0000000000..2b0fe1dfef --- /dev/null +++ b/src/renderer/src/hooks/use-mobile.ts @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener("change", onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener("change", onChange) + }, []) + + return !!isMobile +} diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 27bbb8b41f..86f5caf1f3 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1814,6 +1814,13 @@ "service_tier.flex": "灵活" } }, + "discover": { + "title": "发现", + "install": "安装", + "uninstall": "卸载", + "update": "更新", + "update_all": "全部更新" + }, "translate": { "any.language": "任意语言", "target_language": "目标语言", diff --git a/src/renderer/src/pages/agents/AgentsPage.tsx b/src/renderer/src/pages/agents/AgentsPage.tsx index cd64694e1b..7de7819a74 100644 --- a/src/renderer/src/pages/agents/AgentsPage.tsx +++ b/src/renderer/src/pages/agents/AgentsPage.tsx @@ -1,5 +1,4 @@ import { ImportOutlined, PlusOutlined } from '@ant-design/icons' -import { NavbarCenter, NavbarMain } from '@renderer/components/app/Navbar' import CustomTag from '@renderer/components/CustomTag' import ListItem from '@renderer/components/ListItem' import Scrollbar from '@renderer/components/Scrollbar' @@ -152,27 +151,23 @@ const AgentsPage: FC = () => { return ( - - - {t('agents.title')} - } - value={searchInput} - maxLength={50} - onChange={(e) => setSearchInput(e.target.value)} - onPressEnter={handleSearch} - /> -
- - - +
+ } + value={searchInput} + maxLength={50} + onChange={(e) => setSearchInput(e.target.value)} + onPressEnter={handleSearch} + /> +
+
{Object.entries(agentGroups).map(([group]) => ( diff --git a/src/renderer/src/pages/apps/AppsPage.tsx b/src/renderer/src/pages/apps/AppsPage.tsx index 39a3c063d2..16b56da165 100644 --- a/src/renderer/src/pages/apps/AppsPage.tsx +++ b/src/renderer/src/pages/apps/AppsPage.tsx @@ -1,7 +1,6 @@ -import { NavbarCenter, NavbarMain } from '@renderer/components/app/Navbar' import { useMinapps } from '@renderer/hooks/useMinapps' import { Button, Input } from 'antd' -import { Search, SettingsIcon } from 'lucide-react' +import { Search, SettingsIcon, X } from 'lucide-react' import React, { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocation } from 'react-router' @@ -41,32 +40,37 @@ const AppsPage: FC = () => { return ( - - - {t('minapp.title')} - } - value={search} - onChange={(e) => setSearch(e.target.value)} - disabled={isSettingsOpen} - /> -
+ {/* */} + {/* */} {isSettingsOpen && } {!isSettingsOpen && ( diff --git a/src/renderer/src/pages/discover/components/DiscoverContent.tsx b/src/renderer/src/pages/discover/components/DiscoverContent.tsx new file mode 100644 index 0000000000..b905e1bc59 --- /dev/null +++ b/src/renderer/src/pages/discover/components/DiscoverContent.tsx @@ -0,0 +1,46 @@ +import { Category } from '@renderer/types/cherryStore' +import React from 'react' +import { Navigate, Route, Routes, useLocation } from 'react-router-dom' + +// 实际的 AgentsPage 组件 - 请确保路径正确 +import AgentsPage from '../../agents/AgentsPage' +import AppsPage from '../../apps/AppsPage' +// import AssistantDetailsPage from '../../agents/AssistantDetailsPage'; // 示例详情页 + +// 其他分类的页面组件 (如果需要) +// const MiniAppPagePlaceholder = ({ categoryId, subcategoryId }: { categoryId?: string; subcategoryId?: string }) => ( +//
+// MiniApp Placeholder for Category: {categoryId || 'N/A'}, Subcategory: {subcategoryId || 'N/A'} +//
+// ) + +export interface DiscoverContentProps { + activeTabId: string // This should be one of the CherryStoreType values, e.g., "Assistant" + // selectedSubcategoryId: string + currentCategory: Category | undefined +} + +const DiscoverContent: React.FC = ({ activeTabId, currentCategory }) => { + const location = useLocation() // To see the current path for debugging or more complex logic + + if (!currentCategory || !activeTabId) { + return
Loading: Category or Tab ID missing...
+ } + + if (!activeTabId && !location.pathname.startsWith('/discover/')) { + return // Fallback redirect, adjust as needed + } + + return ( + + {/* Path for Assistant category */} + } /> + {/* Path for Mini-App category */} + } /> + + Discover Feature Not Found at {location.pathname}
} /> + + ) +} + +export default DiscoverContent diff --git a/src/renderer/src/pages/discover/components/DiscoverSidebar.tsx b/src/renderer/src/pages/discover/components/DiscoverSidebar.tsx new file mode 100644 index 0000000000..aa60d965ef --- /dev/null +++ b/src/renderer/src/pages/discover/components/DiscoverSidebar.tsx @@ -0,0 +1,64 @@ +import { SubCategoryItem } from '@renderer/types/cherryStore' +import { Badge } from '@renderer/ui/badge' +import { + Sidebar, + SidebarContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuSubItem, + SidebarProvider +} from '@renderer/ui/sidebar' + +import { InternalCategory } from '../hooks/useDiscoverCategories' + +interface DiscoverSidebarProps { + activeCategory: InternalCategory | undefined + selectedSubcategory: string + onSelectSubcategory: (subcategoryId: string, row?: SubCategoryItem) => void +} + +export default function DiscoverSidebar({ + activeCategory, + selectedSubcategory, + onSelectSubcategory +}: DiscoverSidebarProps) { + if (!activeCategory) { + return ( + + +

No active category selected.

+
+
+ ) + } + + return ( + + + + + {activeCategory.items && + activeCategory.items.length > 0 && + activeCategory.items.map((subItem) => ( + + { + onSelectSubcategory(subItem.id, subItem) + }} + size="sm"> + {subItem.name} + {typeof subItem.count === 'number' && ( + + {subItem.count} + + )} + + + ))} + + + + + ) +} diff --git a/src/renderer/src/pages/discover/hooks/useDiscoverCategories.ts b/src/renderer/src/pages/discover/hooks/useDiscoverCategories.ts new file mode 100644 index 0000000000..6dcc7b4ee6 --- /dev/null +++ b/src/renderer/src/pages/discover/hooks/useDiscoverCategories.ts @@ -0,0 +1,165 @@ +import { Category, CherryStoreType } from '@renderer/types/cherryStore' +import { useEffect, useMemo, useState } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +// Extended Category type for internal use in hook, including path and sidebar flag +// Export this interface so other files can import it +export interface InternalCategory extends Category { + path: string + hasSidebar?: boolean // Optional: defaults to true if not specified, or handle explicitly +} + +// Initial category data with path and hasSidebar +const initialCategories: InternalCategory[] = [ + { + id: CherryStoreType.ASSISTANT, + title: 'Assistants', + path: 'assistant', + hasSidebar: false, + items: [] + }, + { + id: CherryStoreType.MINI_APP, + title: 'Mini Apps', + path: 'mini-app', + hasSidebar: false, + items: [] + } + // Add more categories as needed +] + +// Helper to find category by path +const findCategoryByPath = (path: string | undefined): InternalCategory | undefined => + initialCategories.find((cat) => cat.path === path) + +// Helper to find category by id (activeTab) +const findCategoryById = (id: string | undefined): InternalCategory | undefined => + initialCategories.find((cat) => cat.id === id) + +export function useDiscoverCategories() { + const [categories, setCategories] = useState(initialCategories) + const [activeTab, setActiveTab] = useState('') + const [selectedSubcategory, setSelectedSubcategory] = useState('all') + + const navigate = useNavigate() + const location = useLocation() + + // Effect to initialize activeTab from URL path segment or navigate to default + useEffect(() => { + const pathSegments = location.pathname.split('/').filter(Boolean) // e.g., ["discover", "assistant"] + // Expects URL like /discover/:categoryPathSegment/... + const currentCategoryPath = pathSegments.length >= 2 && pathSegments[0] === 'discover' ? pathSegments[1] : undefined + + const categoryFromPath = findCategoryByPath(currentCategoryPath) + + // Synchronize active tab with the category determined from the URL path. + // If a category is found from the path, update the active tab to match its ID. + if (categoryFromPath) { + if (activeTab !== categoryFromPath.id) { + setActiveTab(categoryFromPath.id) + } + } else if (location.pathname === '/discover' || location.pathname === '/discover/') { + // Handle the case where the URL is the base /discover path. + // Redirect to the first category's path to ensure a category is always selected. + if (categories.length > 0) { + const firstCategory = categories[0] + if (firstCategory?.path) { + navigate(`/discover/${firstCategory.path}?subcategory=all`, { replace: true }) + } + } + } else if (!currentCategoryPath && categories.length > 0 && !activeTab) { + // Fallback for invalid or unmatched /discover/xxx URLs. + // If the URL contains a path segment that doesn't correspond to a known category, + // and no tab is active, redirect to the first valid category. + const firstCategory = categories[0] + if (firstCategory?.path) { + navigate(`/discover/${firstCategory.path}?subcategory=all`, { replace: true }) + } + } + // If categoryFromPath is undefined, and it's not /discover, it means it's an invalid path like /discover/unknown + // In this case, we don't navigate from here; ideally App.tsx has a NotFound route, or DiscoverContent shows a message. + }, [location.pathname, categories, activeTab, navigate]) + + // Effect to initialize selectedSubcategory from URL query param or default to 'all' + useEffect(() => { + const searchParams = new URLSearchParams(location.search) + const subcategoryIdFromQuery = searchParams.get('subcategory') + const currentCatDetails = findCategoryById(activeTab) // Use the helper here + + if (subcategoryIdFromQuery && currentCatDetails) { + // Check if the subcategory from query is valid for the current active category + if (currentCatDetails.items.some((item) => item.id === subcategoryIdFromQuery)) { + if (selectedSubcategory !== subcategoryIdFromQuery) { + setSelectedSubcategory(subcategoryIdFromQuery) + } + return // Valid subcategory from URL is set, no further action needed in this effect iteration + } + } + + // If no valid subcategory in query, or if activeTab has changed and subcategory needs reset/defaulting + if (activeTab && currentCatDetails) { + const defaultSub = currentCatDetails.items.find((item) => item.id === 'all') || currentCatDetails.items[0] + if (defaultSub) { + // Ensure defaultSub exists + // Set selectedSubcategory state first + if (selectedSubcategory !== defaultSub.id) { + setSelectedSubcategory(defaultSub.id) + } + // Then, if URL doesn't match this default, update URL to reflect the default subcategory + // This ensures the URL is the source of truth / always consistent. + if (!subcategoryIdFromQuery || subcategoryIdFromQuery !== defaultSub.id) { + const newSearchParams = new URLSearchParams() // Start with clean params for this path + newSearchParams.set('subcategory', defaultSub.id) + // Ensure we use the current actual path from currentCatDetails if available for navigation + // This avoids issues if location.pathname is briefly out of sync during transitions. + const basePath = currentCatDetails.path + ? `/discover/${currentCatDetails.path}` + : location.pathname.split('?')[0] + navigate(`${basePath}?${newSearchParams.toString()}`, { replace: true }) + } + } + } + }, [activeTab, location.search, categories, navigate, selectedSubcategory]) // location.pathname removed as basePath logic handles path part + + const currentCategory = useMemo(() => { + return findCategoryById(activeTab) // Use the helper here + }, [activeTab]) // categories removed from deps as findCategoryById uses stable initialCategories + + const handleSelectTab = (tabId: string) => { + const categoryToSelect = findCategoryById(tabId) + if (categoryToSelect && categoryToSelect.path && activeTab !== tabId) { + navigate(`/discover/${categoryToSelect.path}?subcategory=all`) + } + } + + const handleSelectSubcategory = (subcategoryId: string) => { + const currentCatDetails = findCategoryById(activeTab) + if (selectedSubcategory !== subcategoryId && currentCatDetails?.path) { + const newSearchParams = new URLSearchParams() + newSearchParams.set('subcategory', subcategoryId) + navigate(`/discover/${currentCatDetails.path}?${newSearchParams.toString()}`, { replace: false }) + } + } + + // Ensure each category has an "All" subcategory (runs once on mount) + useEffect(() => { + setCategories((prev) => + prev.map((cat) => { + if (!cat.items.some((item) => item.id === 'all')) { + return { ...cat, items: [{ id: 'all', name: `All ${cat.title}` }, ...cat.items] } + } + return cat + }) + ) + }, []) + + return { + categories, + activeTab, + selectedSubcategory, + currentCategory, + handleSelectTab, + handleSelectSubcategory, + setActiveTab + } +} diff --git a/src/renderer/src/pages/discover/index.tsx b/src/renderer/src/pages/discover/index.tsx new file mode 100644 index 0000000000..bdf462e3db --- /dev/null +++ b/src/renderer/src/pages/discover/index.tsx @@ -0,0 +1,83 @@ +import { NavbarCenter, NavbarMain } from '@renderer/components/app/Navbar' +// import { useRuntime } from '@renderer/hooks/useRuntime' // No longer needed if resourcesPath is not used +import { Tabs as VercelTabs } from '@renderer/ui/vercel-tabs' +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' + +// Import Context and the main Dialog Manager component +import DiscoverContent from './components/DiscoverContent' // Removed DiscoverContent import +import DiscoverSidebar from './components/DiscoverSidebar' +import { InternalCategory, useDiscoverCategories } from './hooks/useDiscoverCategories' + +// Function to adapt categories for VercelTabs +const adaptCategoriesForVercelTabs = (categories: InternalCategory[]) => { + return categories.map((category) => ({ + id: category.id, // VercelTabs expects `id` + label: category.title // VercelTabs expects `label` + })) +} + +export default function DiscoverPage() { + const { t } = useTranslation() + const { + categories, + activeTab, + selectedSubcategory, + currentCategory, + handleSelectTab, + handleSelectSubcategory, + setActiveTab + } = useDiscoverCategories() + + // Path like /discover/:categoryIdFromUrl. categoryIdFromUrl is lowercase from URL. + const { categoryIdFromUrl } = useParams<{ categoryIdFromUrl: string }>() + + useEffect(() => { + const matchedCategory = categories.find((cat) => cat.id.toLowerCase() === categoryIdFromUrl?.toLowerCase()) + if (matchedCategory && activeTab !== matchedCategory.id) { + setActiveTab(matchedCategory.id) + } + }, [categoryIdFromUrl, categories, activeTab, setActiveTab]) + + const vercelTabsData = adaptCategoriesForVercelTabs(categories) + + return ( +
+
+ + {t('discover.title')} + + + {categories.length > 0 && ( +
+ +
+ )} + +
+ {currentCategory?.hasSidebar && ( +
+ +
+ )} + {/* {!currentCategory && categories.length > 0 && ( +
Select a category...
+ )} */} + +
+ +
+
+
+
+ ) +} diff --git a/src/renderer/src/pages/discover/types.ts b/src/renderer/src/pages/discover/types.ts new file mode 100644 index 0000000000..5a7b7557b9 --- /dev/null +++ b/src/renderer/src/pages/discover/types.ts @@ -0,0 +1,7 @@ +import { Category } from '@renderer/types/cherryStore' + +export interface DiscoverContextType { + selectedSubcategory: string + activeTabId: string + currentCategory?: Category // currentCategory might be undefined initially +} diff --git a/src/renderer/src/pages/settings/AssistantSettings/index.tsx b/src/renderer/src/pages/settings/AssistantSettings/index.tsx index 350c7d571c..d7a979cf88 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/index.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/index.tsx @@ -80,7 +80,7 @@ const AssistantSettingPopupContainer: React.FC = ({ resolve, tab, ...prop { + try { + const visibleIcons = state.settings.sidebarIcons.visible + if (visibleIcons.includes('discover')) { + return state + } + const filteredIcons = visibleIcons.filter((icon) => icon !== 'agents' && icon !== 'minapp') + return { + ...state, + settings: { + ...state.settings, + sidebarIcons: { + ...state.settings.sidebarIcons, + visible: [...filteredIcons, 'discover'] + } + } + } + } catch (error) { + return state + } } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 429c48ef92..d487cf3828 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -16,7 +16,15 @@ import { WebDAVSyncState } from './backup' export type SendMessageShortcut = 'Enter' | 'Shift+Enter' | 'Ctrl+Enter' | 'Command+Enter' -export type SidebarIcon = 'assistants' | 'agents' | 'paintings' | 'translate' | 'minapp' | 'knowledge' | 'files' +export type SidebarIcon = + | 'assistants' + | 'agents' + | 'paintings' + | 'translate' + | 'minapp' + | 'knowledge' + | 'files' + | 'discover' export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [ 'assistants', @@ -25,7 +33,8 @@ export const DEFAULT_SIDEBAR_ICONS: SidebarIcon[] = [ 'translate', 'minapp', 'knowledge', - 'files' + 'files', + 'discover' ] export interface NutstoreSyncRuntime extends WebDAVSyncState {} diff --git a/src/renderer/src/types/cherryStore.ts b/src/renderer/src/types/cherryStore.ts new file mode 100644 index 0000000000..b3965526af --- /dev/null +++ b/src/renderer/src/types/cherryStore.ts @@ -0,0 +1,52 @@ +export enum CherryStoreType { + ASSISTANT = 'Assistant', + MINI_APP = 'Mini-App', + KNOWLEDGE = 'Knowledge', + MCP_SERVER = 'MCP-Server', + MODEL_PROVIDER = 'Model-Provider', + AGENT = 'Agent' +} +export interface CherryStoreBaseItem { + id: string + title: string + description: string + categoryId: string + subcategoryId: string + author: string + image: string + tags: string[] + // rating: number + // downloads: string + // featured: boolean + // requirements: string[] +} + +export interface SubCategoryItem { + id: string + name: string + count?: number // count 是可选的,因为并非所有二级分类都有 + isActive?: boolean +} + +export interface Category { + id: CherryStoreType + title: string + items: SubCategoryItem[] +} + +export interface AssistantItem extends CherryStoreBaseItem { + type: CherryStoreType.ASSISTANT + icon?: string + prompt?: string +} + +export interface MiniAppItem extends CherryStoreBaseItem { + type: CherryStoreType.MINI_APP + url: string + bodered?: boolean + style?: { + padding?: number + } +} + +export type CherryStoreItem = AssistantItem | MiniAppItem diff --git a/src/renderer/src/ui/alert.tsx b/src/renderer/src/ui/alert.tsx new file mode 100644 index 0000000000..7d0a2918d6 --- /dev/null +++ b/src/renderer/src/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@renderer/utils/index" + +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) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/renderer/src/ui/badge.tsx b/src/renderer/src/ui/badge.tsx new file mode 100644 index 0000000000..153a11bde6 --- /dev/null +++ b/src/renderer/src/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@renderer/utils/index" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/renderer/src/ui/button.tsx b/src/renderer/src/ui/button.tsx new file mode 100644 index 0000000000..e48aff75d8 --- /dev/null +++ b/src/renderer/src/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@renderer/utils/index" + +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", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/src/renderer/src/ui/card.tsx b/src/renderer/src/ui/card.tsx new file mode 100644 index 0000000000..bfcae7db09 --- /dev/null +++ b/src/renderer/src/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@renderer/utils/index" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/renderer/src/ui/collapsible.tsx b/src/renderer/src/ui/collapsible.tsx new file mode 100644 index 0000000000..77f86bedad --- /dev/null +++ b/src/renderer/src/ui/collapsible.tsx @@ -0,0 +1,31 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/src/renderer/src/ui/dialog.tsx b/src/renderer/src/ui/dialog.tsx new file mode 100644 index 0000000000..c41d175504 --- /dev/null +++ b/src/renderer/src/ui/dialog.tsx @@ -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 "@renderer/utils/index" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/src/renderer/src/ui/dropdown-menu.tsx b/src/renderer/src/ui/dropdown-menu.tsx new file mode 100644 index 0000000000..4c5417cae5 --- /dev/null +++ b/src/renderer/src/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@renderer/utils/index" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/src/renderer/src/ui/input.tsx b/src/renderer/src/ui/input.tsx new file mode 100644 index 0000000000..5e867e49aa --- /dev/null +++ b/src/renderer/src/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@renderer/utils/index" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/src/renderer/src/ui/separator.tsx b/src/renderer/src/ui/separator.tsx new file mode 100644 index 0000000000..f8996b2b0e --- /dev/null +++ b/src/renderer/src/ui/separator.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import * as SeparatorPrimitive from "@radix-ui/react-separator" + +import { cn } from "@renderer/utils/index" + +function Separator({ + className, + orientation = "horizontal", + decorative = true, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Separator } diff --git a/src/renderer/src/ui/sheet.tsx b/src/renderer/src/ui/sheet.tsx new file mode 100644 index 0000000000..0bff33aa8d --- /dev/null +++ b/src/renderer/src/ui/sheet.tsx @@ -0,0 +1,137 @@ +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@renderer/utils/index" + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/src/renderer/src/ui/sidebar.tsx b/src/renderer/src/ui/sidebar.tsx new file mode 100644 index 0000000000..6ecb280776 --- /dev/null +++ b/src/renderer/src/ui/sidebar.tsx @@ -0,0 +1,724 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { VariantProps, cva } from "class-variance-authority" +import { PanelLeftIcon } from "lucide-react" + +import { useIsMobile } from "@renderer/hooks/use-mobile" +import { cn } from "@renderer/utils/index" +import { Button } from "@renderer/ui/button" +import { Input } from "@renderer/ui/input" +import { Separator } from "@renderer/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@renderer/ui/sheet" +import { Skeleton } from "@renderer/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@renderer/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(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 +}) { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // 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(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // 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( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) +} + +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 { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) +} + +function SidebarTrigger({ + className, + onClick, + ...props +}: React.ComponentProps) { + const { toggleSidebar } = useSidebar() + + return ( + + ) +} + +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { + const { toggleSidebar } = useSidebar() + + return ( +