From 0a0956cfc436f183a09efdbb866a8bcf3f03f0ac Mon Sep 17 00:00:00 2001 From: MyPrototypeWhat Date: Mon, 12 May 2025 19:28:23 +0800 Subject: [PATCH] feat: update store page and integrate new UI components - Updated Tailwind CSS configuration and styles for the store page. - Added new UI components including Card, Badge, DropdownMenu, and Sidebar for enhanced user experience. - Implemented store categories and items with filtering functionality. - Introduced mobile responsiveness with a custom hook for detecting mobile devices. - Enhanced theme management to support dynamic theme changes. - Added new utility functions for improved class name management. --- components.json | 4 +- electron.vite.config.ts | 4 +- package.json | 7 +- resources/data/store_categories.json | 42 ++ resources/data/store_list.json | 114 +++ src/renderer/src/assets/styles/tailwind.css | 76 +- src/renderer/src/context/ThemeProvider.tsx | 7 + src/renderer/src/hooks/use-mobile.ts | 19 + src/renderer/src/pages/store/index.tsx | 291 +++++++- src/renderer/src/ui/badge.tsx | 46 ++ src/renderer/src/ui/card.tsx | 92 +++ src/renderer/src/ui/dropdown-menu.tsx | 257 +++++++ src/renderer/src/ui/input.tsx | 21 + src/renderer/src/ui/separator.tsx | 28 + src/renderer/src/ui/sheet.tsx | 137 ++++ src/renderer/src/ui/sidebar.tsx | 724 ++++++++++++++++++++ src/renderer/src/ui/skeleton.tsx | 13 + src/renderer/src/ui/tabs.tsx | 64 ++ src/renderer/src/ui/tooltip.tsx | 61 ++ yarn.lock | 718 ++++++++++++++++++- 20 files changed, 2661 insertions(+), 64 deletions(-) create mode 100644 resources/data/store_categories.json create mode 100644 resources/data/store_list.json create mode 100644 src/renderer/src/hooks/use-mobile.ts create mode 100644 src/renderer/src/ui/badge.tsx create mode 100644 src/renderer/src/ui/card.tsx create mode 100644 src/renderer/src/ui/dropdown-menu.tsx create mode 100644 src/renderer/src/ui/input.tsx create mode 100644 src/renderer/src/ui/separator.tsx create mode 100644 src/renderer/src/ui/sheet.tsx create mode 100644 src/renderer/src/ui/sidebar.tsx create mode 100644 src/renderer/src/ui/skeleton.tsx create mode 100644 src/renderer/src/ui/tabs.tsx create mode 100644 src/renderer/src/ui/tooltip.tsx diff --git a/components.json b/components.json index 25d52d4e93..283cb015f5 100644 --- a/components.json +++ b/components.json @@ -5,8 +5,8 @@ "tsx": true, "tailwind": { "config": "", - "css": "src/styles/globals.css", - "baseColor": "neutral", + "css": "src/renderer/src/assets/styles/tailwind.css", + "baseColor": "zinc", "cssVariables": true, "prefix": "" }, diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 49a2e824ad..9ee55685b1 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -51,6 +51,7 @@ export default defineConfig({ }, renderer: { plugins: [ + tailwindcss(), react({ plugins: [ [ @@ -64,8 +65,7 @@ export default defineConfig({ ] ] }), - ...visualizerPlugin('renderer'), - tailwindcss() + ...visualizerPlugin('renderer') ], resolve: { alias: { diff --git a/package.json b/package.json index 36b6a49484..1e3ca06b95 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,12 @@ "@electron-toolkit/utils": "^3.0.0", "@electron/notarize": "^2.5.0", "@langchain/community": "^0.3.36", + "@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", "@strongtz/win32-arm64-msvc": "^0.4.7", "@tanstack/react-query": "^5.27.0", "@types/react-infinite-scroll-component": "^5.0.0", @@ -181,7 +186,7 @@ "lint-staged": "^15.5.0", "lodash": "^4.17.21", "lru-cache": "^11.1.0", - "lucide-react": "^0.508.0", + "lucide-react": "^0.509.0", "mime": "^4.0.4", "npx-scope-finder": "^1.2.0", "openai": "patch:openai@npm%3A4.96.0#~/.yarn/patches/openai-npm-4.96.0-0665b05cb9.patch", diff --git a/resources/data/store_categories.json b/resources/data/store_categories.json new file mode 100644 index 0000000000..218acf7128 --- /dev/null +++ b/resources/data/store_categories.json @@ -0,0 +1,42 @@ +[ + { + "id": "all", + "title": "Categories", + "items": [ + { "id": "all", "name": "All", "count": 120, "isActive": true }, + { "id": "featured", "name": "Featured", "count": 24 }, + { "id": "new", "name": "New Releases", "count": 18 }, + { "id": "top", "name": "Top Rated", "count": 32 } + ] + }, + { + "id": "mcp", + "title": "MCP Services", + "items": [ + { "id": "mcp-text", "name": "Text Generation", "count": 15 }, + { "id": "mcp-image", "name": "Image Generation", "count": 8 }, + { "id": "mcp-audio", "name": "Audio Processing", "count": 6 }, + { "id": "mcp-code", "name": "Code Assistance", "count": 12 } + ] + }, + { + "id": "plugins", + "title": "Plugins", + "items": [ + { "id": "plugin-productivity", "name": "Productivity", "count": 14 }, + { "id": "plugin-development", "name": "Development", "count": 22 }, + { "id": "plugin-design", "name": "Design", "count": 9 }, + { "id": "plugin-utilities", "name": "Utilities", "count": 18 } + ] + }, + { + "id": "apps", + "title": "Applications", + "items": [ + { "id": "app-desktop", "name": "Desktop", "count": 7 }, + { "id": "app-web", "name": "Web", "count": 11 }, + { "id": "app-mobile", "name": "Mobile", "count": 5 }, + { "id": "app-cli", "name": "CLI", "count": 8 } + ] + } +] diff --git a/resources/data/store_list.json b/resources/data/store_list.json new file mode 100644 index 0000000000..b17caeb538 --- /dev/null +++ b/resources/data/store_list.json @@ -0,0 +1,114 @@ +[ + { + "id": 1, + "title": "GPT-4 Turbo", + "description": "Advanced language model with improved reasoning capabilities", + "type": "MCP Service", + "categoryId": "mcp", + "subcategoryId": "mcp-text", + "author": "OpenAI", + "rating": 4.9, + "downloads": "1.2M", + "image": "/placeholder.svg?height=200&width=200", + "tags": ["Text Generation", "Featured"], + "featured": true + }, + { + "id": 2, + "title": "Claude 3 Opus", + "description": "High-performance model for complex reasoning and content generation", + "type": "MCP Service", + "categoryId": "mcp", + "subcategoryId": "mcp-text", + "author": "Anthropic", + "rating": 4.8, + "downloads": "850K", + "image": "/placeholder.svg?height=200&width=200", + "tags": ["Text Generation", "Featured"], + "featured": true + }, + { + "id": 3, + "title": "Midjourney Connect", + "description": "Integration plugin for Midjourney image generation", + "type": "Plugin", + "categoryId": "plugins", + "subcategoryId": "plugin-design", + "author": "Cherry Studio", + "rating": 4.7, + "downloads": "620K", + "image": "/placeholder.svg?height=200&width=200", + "tags": ["Image Generation", "Design"], + "featured": false + }, + { + "id": 4, + "title": "Code Interpreter", + "description": "Execute and analyze code within your conversations", + "type": "Plugin", + "categoryId": "plugins", + "subcategoryId": "plugin-development", + "author": "Cherry Studio", + "rating": 4.9, + "downloads": "1.5M", + "image": "/placeholder.svg?height=200&width=200", + "tags": ["Development", "Code Assistance", "Featured"], + "featured": true + }, + { + "id": 5, + "title": "Voice Assistant", + "description": "Add voice interaction capabilities to Cherry Studio", + "type": "Application", + "categoryId": "apps", + "subcategoryId": "app-desktop", + "author": "Cherry Audio", + "rating": 4.6, + "downloads": "780K", + "image": "/placeholder.svg?height=200&width=200", + "tags": ["Audio Processing", "Desktop"], + "featured": false + }, + { + "id": 6, + "title": "Stable Diffusion XL", + "description": "High-quality image generation model", + "type": "MCP Service", + "categoryId": "mcp", + "subcategoryId": "mcp-image", + "author": "Stability AI", + "rating": 4.8, + "downloads": "920K", + "image": "/placeholder.svg?height=200&width=200", + "tags": ["Image Generation", "Featured"], + "featured": true + }, + { + "id": 7, + "title": "Knowledge Base", + "description": "Create and manage custom knowledge bases for your LLMs", + "type": "Plugin", + "categoryId": "plugins", + "subcategoryId": "plugin-utilities", + "author": "Cherry Studio", + "rating": 4.7, + "downloads": "540K", + "image": "/placeholder.svg?height=200&width=200", + "tags": ["Productivity", "Utilities"], + "featured": false + }, + { + "id": 8, + "title": "Workflow Automator", + "description": "Create automated workflows with LLMs and other tools", + "type": "Application", + "categoryId": "apps", + "subcategoryId": "app-desktop", + "author": "Cherry Automation", + "rating": 4.5, + "downloads": "320K", + "image": "/placeholder.svg?height=200&width=200", + "tags": ["Productivity", "Desktop", "Web"], + "featured": false + } +] diff --git a/src/renderer/src/assets/styles/tailwind.css b/src/renderer/src/assets/styles/tailwind.css index 02687bd0b8..f25c11060c 100644 --- a/src/renderer/src/assets/styles/tailwind.css +++ b/src/renderer/src/assets/styles/tailwind.css @@ -4,74 +4,72 @@ @custom-variant dark (&:is(.dark *)); :root { + --radius: 0.625rem; --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); + --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); + --card-foreground: oklch(0.141 0.005 285.823); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 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.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 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); - --destructive-foreground: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --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); - --radius: 0.625rem; --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 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.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 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.145 0 0); + --background: oklch(0.141 0.005 285.823); --foreground: oklch(0.985 0 0); - --card: oklch(0.145 0 0); + --card: oklch(0.21 0.006 285.885); --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.145 0 0); + --popover: oklch(0.21 0.006 285.885); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.985 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 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.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 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.396 0.141 25.723); - --destructive-foreground: oklch(0.637 0.237 25.331); - --border: oklch(0.269 0 0); - --input: oklch(0.269 0 0); - --ring: oklch(0.439 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.205 0 0); + --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.269 0 0); + --sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(0.269 0 0); - --sidebar-ring: oklch(0.439 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.552 0.016 285.938); } @theme inline { diff --git a/src/renderer/src/context/ThemeProvider.tsx b/src/renderer/src/context/ThemeProvider.tsx index 5908e8feba..8d506dbd4b 100644 --- a/src/renderer/src/context/ThemeProvider.tsx +++ b/src/renderer/src/context/ThemeProvider.tsx @@ -39,12 +39,19 @@ export const ThemeProvider: React.FC = ({ children, defaultT } } + const tailwindThemeChange = (theme) => { + const root = window.document.documentElement + root.classList.remove('light', 'dark') + root.classList.add(theme) + } + useEffect(() => { window.api?.setTheme(defaultTheme || theme) }, [defaultTheme, theme]) useEffect(() => { document.body.setAttribute('theme-mode', effectiveTheme) + tailwindThemeChange(effectiveTheme) }, [effectiveTheme]) useEffect(() => { 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/pages/store/index.tsx b/src/renderer/src/pages/store/index.tsx index 323addc1f4..43db372b7a 100644 --- a/src/renderer/src/pages/store/index.tsx +++ b/src/renderer/src/pages/store/index.tsx @@ -1,28 +1,293 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' +import { Badge } from '@renderer/ui/badge' import { Button } from '@renderer/ui/button' -import { Toaster } from '@renderer/ui/sonner' +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@renderer/ui/card' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@renderer/ui/dropdown-menu' +import { Input } from '@renderer/ui/input' +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider +} from '@renderer/ui/sidebar' +import { Tabs, TabsList, TabsTrigger } from '@renderer/ui/tabs' +import { cn } from '@renderer/utils' +import { Download, Filter, Grid3X3, List, Search, Star } from 'lucide-react' +import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { toast } from 'sonner' -export default function Store() { +import categories from '../../../../../resources/data/store_categories.json' +import storeList from '../../../../../resources/data/store_list.json' + +// const categories = [ +// { +// id: 'all', +// title: 'Categories', +// items: [ +// { id: 'all', name: 'All', count: 120, isActive: true }, +// { id: 'featured', name: 'Featured', count: 24 }, +// { id: 'new', name: 'New Releases', count: 18 }, +// { id: 'top', name: 'Top Rated', count: 32 } +// ] +// }, +// { +// id: 'mcp', +// title: 'MCP Services', +// items: [ +// { id: 'mcp-text', name: 'Text Generation', count: 15 }, +// { id: 'mcp-image', name: 'Image Generation', count: 8 }, +// { id: 'mcp-audio', name: 'Audio Processing', count: 6 }, +// { id: 'mcp-code', name: 'Code Assistance', count: 12 } +// ] +// }, +// { +// id: 'plugins', +// title: 'Plugins', +// items: [ +// { id: 'plugin-productivity', name: 'Productivity', count: 14 }, +// { id: 'plugin-development', name: 'Development', count: 22 }, +// { id: 'plugin-design', name: 'Design', count: 9 }, +// { id: 'plugin-utilities', name: 'Utilities', count: 18 } +// ] +// }, +// { +// id: 'apps', +// title: 'Applications', +// items: [ +// { id: 'app-desktop', name: 'Desktop', count: 7 }, +// { id: 'app-web', name: 'Web', count: 11 }, +// { id: 'app-mobile', name: 'Mobile', count: 5 }, +// { id: 'app-cli', name: 'CLI', count: 8 } +// ] +// } +// ] + +export default function StoreLayout() { + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid') + const [searchQuery, setSearchQuery] = useState('') + const [selectedCategory, setSelectedCategory] = useState('all') + const [selectedSubcategory, setSelectedSubcategory] = useState('all') const { t } = useTranslation() + // Update the filteredItems logic to use the new category IDs + const filteredItems = storeList.filter((item) => { + // First apply search filter + const matchesSearch = + searchQuery === '' || + item.title.toLowerCase().includes(searchQuery.toLowerCase()) || + item.description.toLowerCase().includes(searchQuery.toLowerCase()) || + item.author.toLowerCase().includes(searchQuery.toLowerCase()) || + item.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase())) + + // Then apply category filters + const matchesCategory = + selectedCategory === 'all' || + item.categoryId === selectedCategory || + (selectedCategory === 'featured' && item.featured) + + const matchesSubcategory = selectedSubcategory === 'all' || item.subcategoryId === selectedSubcategory + + return matchesSearch && matchesCategory && matchesSubcategory + }) + return (
{t('store.title')}
- + + + +
+

Cherry Store

+
+
+ + {categories.map((category) => ( + + {category.title} + + + {category.items.map((item) => ( + + { + setSelectedCategory(category.id) + setSelectedSubcategory(item.id) + }}> + {item.name} + + {item.count} + + + + ))} + + + + ))} + +
+ +
+
+
+
+
+ + setSearchQuery(e.target.value)} + /> +
+ + + + + + Most Popular + Newest + Highest Rated + + + + +
+ + { + setSelectedCategory(value) + setSelectedSubcategory('all') + }}> + + All + MCP Services + Plugins + Applications + + +
+
+ +
+ {filteredItems.length === 0 ? ( +
+

No items found matching your search.

+
+ ) : viewMode === 'grid' ? ( +
+ {filteredItems.map((item) => ( + + +
+ {item.title} +
+
+ +
+
+ {item.title} +

{item.author}

+
+ {item.type} +
+

{item.description}

+
+ +
+ + {item.rating} +
+ +
+
+ ))} +
+ ) : ( +
+ {filteredItems.map((item) => ( + +
+
+ {item.title} +
+
+
+
+
+

{item.title}

+

{item.author}

+
+ {item.type} +
+

{item.description}

+
+ {item.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+
+
+ + {item.rating} + ({item.downloads}) +
+ +
+
+
+ ))} +
+ )} +
+
+
-
) } 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/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/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 ( +