├── postcss.config.mjs ├── .env.example ├── .prettierrc ├── .vscode └── extensions.json ├── src ├── lib │ └── utils.ts ├── store │ ├── index.ts │ ├── hooks.ts │ └── slices │ │ └── appSlice.ts ├── App.tsx ├── main.tsx ├── features │ ├── chat │ │ ├── types.ts │ │ ├── config.ts │ │ ├── components │ │ │ ├── ChatMessage.tsx │ │ │ ├── ChatHeader.tsx │ │ │ ├── ChatMessage.test.tsx │ │ │ ├── ChatInput.tsx │ │ │ └── ChatInput.test.tsx │ │ ├── README.md │ │ ├── ChatPage.test.tsx │ │ ├── ChatPage.tsx │ │ └── hooks │ │ │ └── useChatStream.ts │ └── weather │ │ ├── config.ts │ │ ├── types.ts │ │ ├── useWeather.ts │ │ └── README.md ├── components │ ├── ui │ │ ├── label.tsx │ │ ├── badge.tsx │ │ ├── tooltip.tsx │ │ ├── button.tsx │ │ └── card.tsx │ ├── Layout.tsx │ ├── PageRouter.tsx │ └── Sidebar.tsx ├── test │ ├── App.test.tsx │ └── setup.ts ├── contexts │ └── NavigationContext.tsx ├── config │ └── navigation.tsx ├── index.css └── pages │ ├── HomePage.tsx │ ├── FeaturesPage.tsx │ ├── AboutPage.tsx │ ├── SettingsPage.tsx │ └── WeatherPage.tsx ├── drizzle.config.ts ├── drizzle ├── meta │ ├── _journal.json │ └── 0000_snapshot.json └── 0000_glossy_odin.sql ├── tsconfig.node.json ├── public ├── icon.svg └── icon.png ├── components.json ├── index.html ├── vitest.config.ts ├── .gitignore ├── tsconfig.json ├── .eslintrc.json ├── vite.config.ts ├── electron ├── main │ ├── system-tray.ts │ ├── schema.ts │ ├── window-state-manager.ts │ ├── index.ts │ ├── database.ts │ └── ipc-handlers.ts ├── shared │ └── types.ts └── preload │ └── index.ts ├── tailwind.config.ts ├── package.json ├── README.md └── CLAUDE.md /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Database 2 | DATABASE_URL="file:./dev.db" 3 | 4 | # Development 5 | VITE_DEV_SERVER_URL=http://localhost:5173 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "es5", 6 | "printWidth": 100, 7 | "arrowParens": "always" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "bradlc.vscode-tailwindcss", 6 | "prisma.prisma" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit' 2 | 3 | export default defineConfig({ 4 | schema: './electron/main/schema.ts', 5 | out: './drizzle', 6 | dialect: 'sqlite', 7 | dbCredentials: { 8 | url: './data/dev.db', 9 | }, 10 | }) 11 | -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1763163033767, 9 | "tag": "0000_glossy_odin", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import appReducer from './slices/appSlice' 3 | 4 | export const store = configureStore({ 5 | reducer: { 6 | app: appReducer, 7 | }, 8 | }) 9 | 10 | export type RootState = ReturnType 11 | export type AppDispatch = typeof store.dispatch 12 | -------------------------------------------------------------------------------- /src/store/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from 'react-redux' 2 | import type { RootState, AppDispatch } from './index' 3 | 4 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 5 | export const useAppDispatch = useDispatch.withTypes() 6 | export const useAppSelector = useSelector.withTypes() 7 | -------------------------------------------------------------------------------- /drizzle/0000_glossy_odin.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `users` ( 2 | `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, 3 | `email` text NOT NULL, 4 | `name` text, 5 | `created_at` text DEFAULT (datetime('now')) NOT NULL, 6 | `updated_at` text DEFAULT (datetime('now')) NOT NULL 7 | ); 8 | --> statement-breakpoint 9 | CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`); -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationProvider } from './contexts/NavigationContext' 2 | import { Layout } from './components/Layout' 3 | import { PageRouter } from './components/PageRouter' 4 | 5 | function App() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ) 13 | } 14 | 15 | export default App 16 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { Provider } from 'react-redux' 4 | import { store } from './store' 5 | import App from './App' 6 | import './index.css' 7 | 8 | ReactDOM.createRoot(document.getElementById('root')!).render( 9 | 10 | 11 | 12 | 13 | 14 | ) 15 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Electron Boilerplate 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- 1 | data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEgAACxIB0t1+/AAAABx0RVh0U29mdHdhcmUAQWRvYmUgRmlyZXdvcmtzIENTNui8sowAAAAWdEVYdENyZWF0aW9uIFRpbWUAMTEvMTQvMjSkdRqNAAAA+klEQVR4nO3BAQ0AAADCoPdPbQ8HFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOB3A0YQAAFlQ/fOAAAAAElFTkSuQmCC -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import react from '@vitejs/plugin-react' 3 | import path from 'node:path' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | test: { 8 | globals: true, 9 | environment: 'jsdom', 10 | setupFiles: './src/test/setup.ts', 11 | css: true, 12 | }, 13 | resolve: { 14 | alias: { 15 | '@': path.resolve(__dirname, './src'), 16 | '@main': path.resolve(__dirname, './electron/main'), 17 | '@preload': path.resolve(__dirname, './electron/preload'), 18 | }, 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /src/features/chat/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Chat Feature Types 3 | */ 4 | 5 | export interface ChatMessage { 6 | id: string 7 | role: 'user' | 'assistant' | 'system' 8 | content: string 9 | timestamp: number 10 | } 11 | 12 | export interface ChatRequest { 13 | messages: ChatMessage[] 14 | model?: string 15 | temperature?: number 16 | maxTokens?: number 17 | } 18 | 19 | export interface ChatStreamChunk { 20 | type: 'content' | 'done' | 'error' 21 | content?: string 22 | error?: string 23 | } 24 | 25 | export interface ChatSettings { 26 | apiKey: string 27 | model: string 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-electron 13 | release 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Environment files 27 | .env 28 | .env.local 29 | 30 | # Database (SQLite) 31 | *.db 32 | *.db-journal 33 | *.db-shm 34 | *.db-wal 35 | data/ 36 | 37 | # Drizzle migrations metadata (migrations SQL should be committed) 38 | # drizzle/meta/ 39 | 40 | # Testing 41 | coverage 42 | *.lcov 43 | 44 | # OS 45 | Thumbs.db 46 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as LabelPrimitive from '@radix-ui/react-label' 3 | import { cva, type VariantProps } from 'class-variance-authority' 4 | 5 | import { cn } from '@/lib/utils' 6 | 7 | const labelVariants = cva( 8 | 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & VariantProps 14 | >(({ className, ...props }, ref) => ( 15 | 16 | )) 17 | Label.displayName = LabelPrimitive.Root.displayName 18 | 19 | export { Label } 20 | -------------------------------------------------------------------------------- /src/test/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { render, screen } from '@testing-library/react' 3 | import { Provider } from 'react-redux' 4 | import { store } from '../store' 5 | import App from '../App' 6 | 7 | describe('App', () => { 8 | it('renders the app title', () => { 9 | render( 10 | 11 | 12 | 13 | ) 14 | expect(screen.getByText('Electron Boilerplate')).toBeDefined() 15 | }) 16 | 17 | it('renders demo actions', () => { 18 | render( 19 | 20 | 21 | 22 | ) 23 | expect(screen.getByText('Show Notification')).toBeDefined() 24 | expect(screen.getByText('Open GitHub')).toBeDefined() 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /src/features/weather/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Weather Feature Configuration 3 | * 4 | * Change these constants to customize the weather feature. 5 | */ 6 | 7 | import type { WeatherLocation } from './types' 8 | 9 | // Default location: Beijing, China 10 | export const DEFAULT_LOCATION: WeatherLocation = { 11 | latitude: 39.9042, 12 | longitude: 116.4074, 13 | city: 'Beijing', 14 | } 15 | 16 | // API endpoint 17 | export const WEATHER_API_URL = 'https://api.open-meteo.com/v1/forecast' 18 | 19 | // API parameters 20 | export const WEATHER_API_PARAMS = { 21 | current: 'temperature_2m,wind_speed_10m', 22 | hourly: 'temperature_2m,relative_humidity_2m,wind_speed_10m', 23 | } 24 | 25 | // Refresh interval (in milliseconds) 26 | export const WEATHER_REFRESH_INTERVAL = 10 * 60 * 1000 // 10 minutes 27 | -------------------------------------------------------------------------------- /src/store/slices/appSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, type PayloadAction } from '@reduxjs/toolkit' 2 | 3 | interface AppState { 4 | version: string 5 | theme: 'light' | 'dark' 6 | } 7 | 8 | const initialState: AppState = { 9 | version: '', 10 | theme: 'light', 11 | } 12 | 13 | const appSlice = createSlice({ 14 | name: 'app', 15 | initialState, 16 | reducers: { 17 | setVersion: (state, action: PayloadAction) => { 18 | state.version = action.payload 19 | }, 20 | setTheme: (state, action: PayloadAction<'light' | 'dark'>) => { 21 | state.theme = action.payload 22 | }, 23 | toggleTheme: (state) => { 24 | state.theme = state.theme === 'light' ? 'dark' : 'light' 25 | }, 26 | }, 27 | }) 28 | 29 | export const { setVersion, setTheme, toggleTheme } = appSlice.actions 30 | export default appSlice.reducer 31 | -------------------------------------------------------------------------------- /src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { type ReactNode } from 'react' 2 | import { Sidebar } from './Sidebar' 3 | import { useNavigation } from '@/contexts/NavigationContext' 4 | 5 | interface LayoutProps { 6 | children: ReactNode 7 | } 8 | 9 | export function Layout({ children }: LayoutProps) { 10 | const { currentPage } = useNavigation() 11 | 12 | // Chat page needs full height without padding 13 | const isFullHeightPage = currentPage === 'chat' 14 | 15 | return ( 16 |
17 | 18 |
19 | {isFullHeightPage ? ( 20 | children 21 | ) : ( 22 |
{children}
23 | )} 24 |
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "esModuleInterop": true, 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "jsx": "react-jsx", 15 | "strict": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": ["./src/*"], 22 | "@main/*": ["./electron/main/*"], 23 | "@preload/*": ["./electron/preload/*"] 24 | } 25 | }, 26 | "include": ["src", "electron"], 27 | "references": [{ "path": "./tsconfig.node.json" }] 28 | } 29 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true 6 | }, 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:react/recommended", 11 | "plugin:react-hooks/recommended", 12 | "prettier" 13 | ], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "ecmaVersion": "latest", 17 | "sourceType": "module", 18 | "ecmaFeatures": { 19 | "jsx": true 20 | } 21 | }, 22 | "plugins": ["@typescript-eslint", "react", "react-hooks"], 23 | "rules": { 24 | "react/react-in-jsx-scope": "off", 25 | "@typescript-eslint/no-unused-vars": [ 26 | "warn", 27 | { 28 | "argsIgnorePattern": "^_", 29 | "varsIgnorePattern": "^_" 30 | } 31 | ] 32 | }, 33 | "settings": { 34 | "react": { 35 | "version": "detect" 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import electron from 'vite-plugin-electron/simple' 4 | import path from 'node:path' 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | electron({ 10 | main: { 11 | entry: 'electron/main/index.ts', 12 | vite: { 13 | build: { 14 | outDir: 'dist-electron/main', 15 | rollupOptions: { 16 | external: ['better-sqlite3'], 17 | }, 18 | }, 19 | }, 20 | }, 21 | preload: { 22 | input: 'electron/preload/index.ts', 23 | vite: { 24 | build: { 25 | outDir: 'dist-electron/preload', 26 | }, 27 | }, 28 | }, 29 | renderer: {}, 30 | }), 31 | ], 32 | resolve: { 33 | alias: { 34 | '@': path.resolve(__dirname, './src'), 35 | '@main': path.resolve(__dirname, './electron/main'), 36 | '@preload': path.resolve(__dirname, './electron/preload'), 37 | }, 38 | }, 39 | server: { 40 | port: 5173, 41 | }, 42 | }) 43 | -------------------------------------------------------------------------------- /src/contexts/NavigationContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState, useCallback, type ReactNode } from 'react' 2 | import type { PageId } from '@/config/navigation' 3 | 4 | interface NavigationContextType { 5 | currentPage: PageId 6 | navigateTo: (pageId: PageId) => void 7 | } 8 | 9 | const NavigationContext = createContext(undefined) 10 | 11 | interface NavigationProviderProps { 12 | children: ReactNode 13 | defaultPage?: PageId 14 | } 15 | 16 | export function NavigationProvider({ children, defaultPage = 'home' }: NavigationProviderProps) { 17 | const [currentPage, setCurrentPage] = useState(defaultPage) 18 | 19 | const navigateTo = useCallback((pageId: PageId) => { 20 | setCurrentPage(pageId) 21 | }, []) 22 | 23 | return ( 24 | 25 | {children} 26 | 27 | ) 28 | } 29 | 30 | export function useNavigation() { 31 | const context = useContext(NavigationContext) 32 | if (!context) { 33 | throw new Error('useNavigation must be used within NavigationProvider') 34 | } 35 | return context 36 | } 37 | -------------------------------------------------------------------------------- /src/features/chat/config.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Chat Feature Configuration 3 | * 4 | * Modify these values to customize the chat experience 5 | */ 6 | 7 | export const chatConfig = { 8 | /** 9 | * Default OpenAI model to use for chat 10 | * Options: 'gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo' 11 | */ 12 | defaultModel: 'gpt-4o-mini', 13 | 14 | /** 15 | * System prompt that sets the AI's behavior 16 | */ 17 | systemPrompt: 'You are a helpful AI assistant.', 18 | 19 | /** 20 | * Maximum number of tokens in the response 21 | */ 22 | maxTokens: 2000, 23 | 24 | /** 25 | * Temperature for response randomness (0-2) 26 | * Lower = more focused, Higher = more creative 27 | */ 28 | temperature: 0.7, 29 | 30 | /** 31 | * UI Configuration 32 | */ 33 | ui: { 34 | placeholder: 'Type your message...', 35 | sendButtonText: 'Send', 36 | emptyStateTitle: 'Start a conversation', 37 | emptyStateDescription: 'Send a message to begin chatting with AI', 38 | errorTitle: 'Error', 39 | apiKeyMissingMessage: 'Please configure your OpenAI API key in Settings', 40 | }, 41 | } as const 42 | 43 | export type ChatModel = 'gpt-4o' | 'gpt-4o-mini' | 'gpt-4-turbo' | 'gpt-3.5-turbo' 44 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { cva, type VariantProps } from 'class-variance-authority' 3 | 4 | import { cn } from '@/lib/utils' 5 | 6 | const badgeVariants = cva( 7 | 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 8 | { 9 | variants: { 10 | variant: { 11 | default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', 12 | secondary: 13 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', 14 | destructive: 15 | 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', 16 | outline: 'text-foreground', 17 | }, 18 | }, 19 | defaultVariants: { 20 | variant: 'default', 21 | }, 22 | } 23 | ) 24 | 25 | export interface BadgeProps 26 | extends React.HTMLAttributes, 27 | VariantProps {} 28 | 29 | function Badge({ className, variant, ...props }: BadgeProps) { 30 | return
31 | } 32 | 33 | export { Badge, badgeVariants } 34 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as TooltipPrimitive from '@radix-ui/react-tooltip' 3 | 4 | import { cn } from '@/lib/utils' 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider 7 | 8 | const Tooltip = TooltipPrimitive.Root 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 25 | )) 26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 27 | 28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 29 | -------------------------------------------------------------------------------- /src/features/weather/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Weather Feature Types 3 | * 4 | * This file contains all TypeScript types for the weather feature. 5 | * Based on Open-Meteo API response structure. 6 | */ 7 | 8 | export interface WeatherLocation { 9 | latitude: number 10 | longitude: number 11 | city: string 12 | } 13 | 14 | export interface CurrentWeather { 15 | time: string 16 | interval: number 17 | temperature_2m: number 18 | wind_speed_10m: number 19 | } 20 | 21 | export interface CurrentUnits { 22 | time: string 23 | interval: string 24 | temperature_2m: string 25 | wind_speed_10m: string 26 | } 27 | 28 | export interface HourlyWeather { 29 | time: string[] 30 | temperature_2m: number[] 31 | relative_humidity_2m: number[] 32 | wind_speed_10m: number[] 33 | } 34 | 35 | export interface HourlyUnits { 36 | time: string 37 | temperature_2m: string 38 | relative_humidity_2m: string 39 | wind_speed_10m: string 40 | } 41 | 42 | export interface WeatherResponse { 43 | latitude: number 44 | longitude: number 45 | generationtime_ms: number 46 | utc_offset_seconds: number 47 | timezone: string 48 | timezone_abbreviation: string 49 | elevation: number 50 | current_units: CurrentUnits 51 | current: CurrentWeather 52 | hourly_units: HourlyUnits 53 | hourly: HourlyWeather 54 | } 55 | 56 | export interface WeatherError { 57 | message: string 58 | code?: string 59 | } 60 | -------------------------------------------------------------------------------- /src/features/chat/components/ChatMessage.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Chat message component 3 | */ 4 | 5 | import { Bot, User } from 'lucide-react' 6 | import type { ChatMessage as ChatMessageType } from '../types' 7 | import { cn } from '@/lib/utils' 8 | 9 | interface ChatMessageProps { 10 | message: ChatMessageType 11 | } 12 | 13 | export function ChatMessage({ message }: ChatMessageProps) { 14 | const isUser = message.role === 'user' 15 | 16 | return ( 17 |
23 |
29 | {isUser ? ( 30 | 31 | ) : ( 32 | 33 | )} 34 |
35 |
36 |

37 | {isUser ? 'You' : 'Assistant'} 38 |

39 |
40 | {message.content || ( 41 | 42 | )} 43 |
44 |
45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/features/chat/components/ChatHeader.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Chat header component 3 | */ 4 | 5 | import { Trash2 } from 'lucide-react' 6 | import { Button } from '@/components/ui/button' 7 | import { 8 | Tooltip, 9 | TooltipContent, 10 | TooltipProvider, 11 | TooltipTrigger, 12 | } from '@/components/ui/tooltip' 13 | 14 | interface ChatHeaderProps { 15 | onClear: () => void 16 | messageCount: number 17 | } 18 | 19 | export function ChatHeader({ onClear, messageCount }: ChatHeaderProps) { 20 | return ( 21 |
22 |
23 |

AI Chat

24 |

25 | {messageCount === 0 26 | ? 'Start a conversation' 27 | : `${messageCount} message${messageCount === 1 ? '' : 's'}`} 28 |

29 |
30 | {messageCount > 0 && ( 31 | 32 | 33 | 34 | 42 | 43 | 44 |

Clear conversation

45 |
46 |
47 |
48 | )} 49 |
50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/PageRouter.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigation } from '@/contexts/NavigationContext' 2 | import { HomePage } from '@/pages/HomePage' 3 | import { FeaturesPage } from '@/pages/FeaturesPage' 4 | import { ChatPage } from '@/features/chat/ChatPage' 5 | import { WeatherPage } from '@/pages/WeatherPage' 6 | import { SettingsPage } from '@/pages/SettingsPage' 7 | import { AboutPage } from '@/pages/AboutPage' 8 | import type { PageId } from '@/config/navigation' 9 | 10 | /** 11 | * Page Router 12 | * 13 | * Maps page IDs to their corresponding components. 14 | * When adding a new page: 15 | * 1. Import the page component 16 | * 2. Add it to the pageComponents map with the same ID from navigation config 17 | */ 18 | const pageComponents: Record JSX.Element> = { 19 | home: HomePage, 20 | features: FeaturesPage, 21 | chat: ChatPage, // Can be removed if chat feature is not needed 22 | weather: WeatherPage, 23 | settings: SettingsPage, 24 | about: AboutPage, 25 | } 26 | 27 | export function PageRouter() { 28 | const { currentPage } = useNavigation() 29 | const PageComponent = pageComponents[currentPage] 30 | 31 | if (!PageComponent) { 32 | return ( 33 |
34 |
35 |

Page Not Found

36 |

37 | The page "{currentPage}" does not exist. 38 |

39 |
40 |
41 | ) 42 | } 43 | 44 | return 45 | } 46 | -------------------------------------------------------------------------------- /src/config/navigation.tsx: -------------------------------------------------------------------------------- 1 | import { Home, Settings, Info, Code, Cloud, MessageSquare } from 'lucide-react' 2 | import type { LucideIcon } from 'lucide-react' 3 | 4 | export interface NavigationItem { 5 | id: string 6 | label: string 7 | icon: LucideIcon 8 | // Optional: for future extensibility 9 | badge?: string | number 10 | disabled?: boolean 11 | divider?: boolean // Show divider after this item 12 | } 13 | 14 | /** 15 | * Navigation Configuration 16 | * 17 | * Add or remove items here to control the sidebar navigation. 18 | * Each item must have a corresponding page in src/pages/ 19 | * 20 | * To add a new page: 21 | * 1. Add item here with unique id 22 | * 2. Create page component in src/pages/{id}Page.tsx 23 | * 3. The navigation system will automatically handle routing 24 | */ 25 | export const navigationItems: NavigationItem[] = [ 26 | { 27 | id: 'home', 28 | label: 'Home', 29 | icon: Home, 30 | }, 31 | { 32 | id: 'features', 33 | label: 'Features', 34 | icon: Code, 35 | }, 36 | { 37 | id: 'chat', 38 | label: 'AI Chat', 39 | icon: MessageSquare, 40 | // Can be removed if chat feature is not needed 41 | }, 42 | { 43 | id: 'weather', 44 | label: 'Weather', 45 | icon: Cloud, 46 | }, 47 | { 48 | id: 'settings', 49 | label: 'Settings', 50 | icon: Settings, 51 | divider: true, // Adds visual separator 52 | }, 53 | { 54 | id: 'about', 55 | label: 'About', 56 | icon: Info, 57 | }, 58 | ] 59 | 60 | // Export page IDs for type safety 61 | export type PageId = (typeof navigationItems)[number]['id'] 62 | -------------------------------------------------------------------------------- /electron/main/system-tray.ts: -------------------------------------------------------------------------------- 1 | import { Tray, Menu, BrowserWindow, app, nativeImage } from 'electron' 2 | import path from 'node:path' 3 | import { VITE_PUBLIC } from './index' 4 | 5 | export function createSystemTray(mainWindow: BrowserWindow | null): Tray { 6 | // Create tray icon (you should add a proper icon file in public/icons/) 7 | const iconPath = path.join(VITE_PUBLIC, 'icon.png') 8 | const icon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 }) 9 | 10 | const tray = new Tray(icon) 11 | tray.setToolTip('Electron Boilerplate') 12 | 13 | const contextMenu = Menu.buildFromTemplate([ 14 | { 15 | label: 'Show App', 16 | click: () => { 17 | mainWindow?.show() 18 | mainWindow?.focus() 19 | }, 20 | }, 21 | { 22 | type: 'separator', 23 | }, 24 | { 25 | label: 'Preferences', 26 | click: () => { 27 | mainWindow?.show() 28 | mainWindow?.webContents.send('navigate-to', '/settings') 29 | }, 30 | }, 31 | { 32 | type: 'separator', 33 | }, 34 | { 35 | label: 'About', 36 | role: 'about', 37 | }, 38 | { 39 | type: 'separator', 40 | }, 41 | { 42 | label: 'Quit', 43 | click: () => { 44 | ;(app as { isQuitting?: boolean }).isQuitting = true 45 | app.quit() 46 | }, 47 | }, 48 | ]) 49 | 50 | tray.setContextMenu(contextMenu) 51 | 52 | // Show window on tray icon click (Windows/Linux) 53 | tray.on('click', () => { 54 | if (mainWindow) { 55 | if (mainWindow.isVisible()) { 56 | mainWindow.hide() 57 | } else { 58 | mainWindow.show() 59 | mainWindow.focus() 60 | } 61 | } 62 | }) 63 | 64 | return tray 65 | } 66 | -------------------------------------------------------------------------------- /src/test/setup.ts: -------------------------------------------------------------------------------- 1 | import { afterEach } from 'vitest' 2 | import { cleanup } from '@testing-library/react' 3 | 4 | // Cleanup after each test 5 | afterEach(() => { 6 | cleanup() 7 | }) 8 | 9 | // Mock electron API for tests 10 | global.window.electron = { 11 | getVersion: async () => ({ success: true, data: '1.0.0' }), 12 | showNotification: async () => ({ success: true, data: undefined }), 13 | openExternal: async () => ({ success: true, data: undefined }), 14 | // Database mocks 15 | dbUsersGetAll: async () => ({ success: true, data: [] }), 16 | dbUsersGetById: async () => ({ 17 | success: true, 18 | data: { 19 | id: 1, 20 | email: 'test@example.com', 21 | name: 'Test User', 22 | createdAt: new Date().toISOString(), 23 | updatedAt: new Date().toISOString(), 24 | }, 25 | }), 26 | dbUsersCreate: async () => ({ 27 | success: true, 28 | data: { 29 | id: 1, 30 | email: 'test@example.com', 31 | name: 'Test User', 32 | createdAt: new Date().toISOString(), 33 | updatedAt: new Date().toISOString(), 34 | }, 35 | }), 36 | dbUsersUpdate: async () => ({ 37 | success: true, 38 | data: { 39 | id: 1, 40 | email: 'test@example.com', 41 | name: 'Updated User', 42 | createdAt: new Date().toISOString(), 43 | updatedAt: new Date().toISOString(), 44 | }, 45 | }), 46 | dbUsersDelete: async () => ({ success: true, data: true }), 47 | // Chat feature mocks (can be removed if chat feature is not needed) 48 | chatSendMessage: async () => ({ success: true, data: { streamId: 'test-stream' } }), 49 | chatGetApiKey: async () => ({ success: true, data: null }), 50 | chatSetApiKey: async () => ({ success: true, data: undefined }), 51 | onChatStream: () => () => {}, 52 | onMainMessage: () => () => {}, 53 | onNavigateTo: () => () => {}, 54 | } 55 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | 3 | const config: Config = { 4 | darkMode: ['class'], 5 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 6 | theme: { 7 | extend: { 8 | colors: { 9 | background: 'hsl(var(--background))', 10 | foreground: 'hsl(var(--foreground))', 11 | card: { 12 | DEFAULT: 'hsl(var(--card))', 13 | foreground: 'hsl(var(--card-foreground))', 14 | }, 15 | popover: { 16 | DEFAULT: 'hsl(var(--popover))', 17 | foreground: 'hsl(var(--popover-foreground))', 18 | }, 19 | primary: { 20 | DEFAULT: 'hsl(var(--primary))', 21 | foreground: 'hsl(var(--primary-foreground))', 22 | }, 23 | secondary: { 24 | DEFAULT: 'hsl(var(--secondary))', 25 | foreground: 'hsl(var(--secondary-foreground))', 26 | }, 27 | muted: { 28 | DEFAULT: 'hsl(var(--muted))', 29 | foreground: 'hsl(var(--muted-foreground))', 30 | }, 31 | accent: { 32 | DEFAULT: 'hsl(var(--accent))', 33 | foreground: 'hsl(var(--accent-foreground))', 34 | }, 35 | destructive: { 36 | DEFAULT: 'hsl(var(--destructive))', 37 | foreground: 'hsl(var(--destructive-foreground))', 38 | }, 39 | border: 'hsl(var(--border))', 40 | input: 'hsl(var(--input))', 41 | ring: 'hsl(var(--ring))', 42 | }, 43 | borderRadius: { 44 | lg: 'var(--radius)', 45 | md: 'calc(var(--radius) - 2px)', 46 | sm: 'calc(var(--radius) - 4px)', 47 | }, 48 | fontFamily: { 49 | sans: ['Inter', 'system-ui', 'sans-serif'], 50 | }, 51 | }, 52 | }, 53 | // eslint-disable-next-line @typescript-eslint/no-require-imports 54 | plugins: [require('tailwindcss-animate')], 55 | } 56 | 57 | export default config 58 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "dialect": "sqlite", 4 | "id": "4a8a805f-a5a0-4053-9b94-12d74f941ce9", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "users": { 8 | "name": "users", 9 | "columns": { 10 | "id": { 11 | "name": "id", 12 | "type": "integer", 13 | "primaryKey": true, 14 | "notNull": true, 15 | "autoincrement": true 16 | }, 17 | "email": { 18 | "name": "email", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": true, 22 | "autoincrement": false 23 | }, 24 | "name": { 25 | "name": "name", 26 | "type": "text", 27 | "primaryKey": false, 28 | "notNull": false, 29 | "autoincrement": false 30 | }, 31 | "created_at": { 32 | "name": "created_at", 33 | "type": "text", 34 | "primaryKey": false, 35 | "notNull": true, 36 | "autoincrement": false, 37 | "default": "(datetime('now'))" 38 | }, 39 | "updated_at": { 40 | "name": "updated_at", 41 | "type": "text", 42 | "primaryKey": false, 43 | "notNull": true, 44 | "autoincrement": false, 45 | "default": "(datetime('now'))" 46 | } 47 | }, 48 | "indexes": { 49 | "users_email_unique": { 50 | "name": "users_email_unique", 51 | "columns": [ 52 | "email" 53 | ], 54 | "isUnique": true 55 | } 56 | }, 57 | "foreignKeys": {}, 58 | "compositePrimaryKeys": {}, 59 | "uniqueConstraints": {}, 60 | "checkConstraints": {} 61 | } 62 | }, 63 | "views": {}, 64 | "enums": {}, 65 | "_meta": { 66 | "schemas": {}, 67 | "tables": {}, 68 | "columns": {} 69 | }, 70 | "internal": { 71 | "indexes": {} 72 | } 73 | } -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Slot } from '@radix-ui/react-slot' 3 | import { cva, type VariantProps } from 'class-variance-authority' 4 | 5 | import { cn } from '@/lib/utils' 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 13 | destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 14 | outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 15 | secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 16 | ghost: 'hover:bg-accent hover:text-accent-foreground', 17 | link: 'text-primary underline-offset-4 hover:underline', 18 | }, 19 | size: { 20 | default: 'h-10 px-4 py-2', 21 | sm: 'h-9 rounded-md px-3', 22 | lg: 'h-11 rounded-md px-8', 23 | icon: 'h-10 w-10', 24 | }, 25 | }, 26 | defaultVariants: { 27 | variant: 'default', 28 | size: 'default', 29 | }, 30 | } 31 | ) 32 | 33 | export interface ButtonProps 34 | extends React.ButtonHTMLAttributes, 35 | VariantProps { 36 | asChild?: boolean 37 | } 38 | 39 | const Button = React.forwardRef( 40 | ({ className, variant, size, asChild = false, ...props }, ref) => { 41 | const Comp = asChild ? Slot : 'button' 42 | return ( 43 | 44 | ) 45 | } 46 | ) 47 | Button.displayName = 'Button' 48 | 49 | export { Button, buttonVariants } 50 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | const Card = React.forwardRef>( 6 | ({ className, ...props }, ref) => ( 7 |
12 | ) 13 | ) 14 | Card.displayName = 'Card' 15 | 16 | const CardHeader = React.forwardRef>( 17 | ({ className, ...props }, ref) => ( 18 |
19 | ) 20 | ) 21 | CardHeader.displayName = 'CardHeader' 22 | 23 | const CardTitle = React.forwardRef>( 24 | ({ className, ...props }, ref) => ( 25 |

30 | ) 31 | ) 32 | CardTitle.displayName = 'CardTitle' 33 | 34 | const CardDescription = React.forwardRef< 35 | HTMLParagraphElement, 36 | React.HTMLAttributes 37 | >(({ className, ...props }, ref) => ( 38 |

39 | )) 40 | CardDescription.displayName = 'CardDescription' 41 | 42 | const CardContent = React.forwardRef>( 43 | ({ className, ...props }, ref) => ( 44 |

45 | ) 46 | ) 47 | CardContent.displayName = 'CardContent' 48 | 49 | const CardFooter = React.forwardRef>( 50 | ({ className, ...props }, ref) => ( 51 |
52 | ) 53 | ) 54 | CardFooter.displayName = 'CardFooter' 55 | 56 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 57 | -------------------------------------------------------------------------------- /src/features/chat/components/ChatMessage.test.tsx: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { render, screen } from '@testing-library/react' 3 | import { ChatMessage } from './ChatMessage' 4 | import type { ChatMessage as ChatMessageType } from '../types' 5 | 6 | describe('ChatMessage', () => { 7 | it('renders user message correctly', () => { 8 | const message: ChatMessageType = { 9 | id: '1', 10 | role: 'user', 11 | content: 'Hello, AI!', 12 | timestamp: Date.now(), 13 | } 14 | 15 | render() 16 | 17 | expect(screen.getByText('You')).toBeDefined() 18 | expect(screen.getByText('Hello, AI!')).toBeDefined() 19 | }) 20 | 21 | it('renders assistant message correctly', () => { 22 | const message: ChatMessageType = { 23 | id: '2', 24 | role: 'assistant', 25 | content: 'Hello! How can I help you?', 26 | timestamp: Date.now(), 27 | } 28 | 29 | render() 30 | 31 | expect(screen.getByText('Assistant')).toBeDefined() 32 | expect(screen.getByText('Hello! How can I help you?')).toBeDefined() 33 | }) 34 | 35 | it('shows loading indicator for empty assistant message', () => { 36 | const message: ChatMessageType = { 37 | id: '3', 38 | role: 'assistant', 39 | content: '', 40 | timestamp: Date.now(), 41 | } 42 | 43 | const { container } = render() 44 | 45 | // Check for the pulsing cursor (loading indicator) 46 | const loadingIndicator = container.querySelector('.animate-pulse') 47 | expect(loadingIndicator).toBeTruthy() 48 | }) 49 | 50 | it('preserves whitespace in message content', () => { 51 | const message: ChatMessageType = { 52 | id: '4', 53 | role: 'user', 54 | content: 'Line 1\nLine 2\nLine 3', 55 | timestamp: Date.now(), 56 | } 57 | 58 | const { container } = render() 59 | 60 | // Check that the content container has whitespace-pre-wrap class 61 | const contentDiv = container.querySelector('.whitespace-pre-wrap') 62 | expect(contentDiv).toBeTruthy() 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /src/features/chat/components/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Chat input component 3 | */ 4 | 5 | import { useState, KeyboardEvent } from 'react' 6 | import { Send } from 'lucide-react' 7 | import { Button } from '@/components/ui/button' 8 | import { chatConfig } from '../config' 9 | 10 | interface ChatInputProps { 11 | onSend: (message: string) => void 12 | disabled?: boolean 13 | } 14 | 15 | export function ChatInput({ onSend, disabled }: ChatInputProps) { 16 | const [input, setInput] = useState('') 17 | 18 | const handleSend = () => { 19 | if (input.trim() && !disabled) { 20 | onSend(input) 21 | setInput('') 22 | } 23 | } 24 | 25 | const handleKeyDown = (e: KeyboardEvent) => { 26 | if (e.key === 'Enter' && !e.shiftKey) { 27 | e.preventDefault() 28 | handleSend() 29 | } 30 | } 31 | 32 | return ( 33 |
34 |
35 |