diff --git a/.gitignore b/.gitignore index 33fbca5b..118f0f6f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,22 @@ node_modules dist *.log .DS_Store -.env -.env.* + +# build outputs +.next +out +.turbo +build +dist-electron +release +*.tsbuildinfo + +# env +.env* + +# platform specific +*.dmg +*.app +*.apk +*.ipa +monorepo.md diff --git a/apps/desktop/.eslintrc.cjs b/apps/desktop/.eslintrc.cjs new file mode 100644 index 00000000..7dbd9d18 --- /dev/null +++ b/apps/desktop/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore new file mode 100644 index 00000000..4108b33e --- /dev/null +++ b/apps/desktop/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/desktop/README.md b/apps/desktop/README.md new file mode 100644 index 00000000..f02aedf8 --- /dev/null +++ b/apps/desktop/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +} +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/apps/desktop/electron-builder.json5 b/apps/desktop/electron-builder.json5 new file mode 100644 index 00000000..cd633dc7 --- /dev/null +++ b/apps/desktop/electron-builder.json5 @@ -0,0 +1,43 @@ +// @see - https://www.electron.build/configuration/configuration +{ + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "appId": "YourAppID", + "asar": true, + "productName": "YourAppName", + "directories": { + "output": "release/${version}" + }, + "files": [ + "dist", + "dist-electron" + ], + "mac": { + "target": [ + "dmg" + ], + "artifactName": "${productName}-Mac-${version}-Installer.${ext}" + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": [ + "x64" + ] + } + ], + "artifactName": "${productName}-Windows-${version}-Setup.${ext}" + }, + "nsis": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true, + "deleteAppDataOnUninstall": false + }, + "linux": { + "target": [ + "AppImage" + ], + "artifactName": "${productName}-Linux-${version}.${ext}" + } +} diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts new file mode 100644 index 00000000..1fdef4b7 --- /dev/null +++ b/apps/desktop/electron/electron-env.d.ts @@ -0,0 +1,27 @@ +/// + +declare namespace NodeJS { + interface ProcessEnv { + /** + * The built directory structure + * + * ```tree + * ├─┬─┬ dist + * │ │ └── index.html + * │ │ + * │ ├─┬ dist-electron + * │ │ ├── main.js + * │ │ └── preload.js + * │ + * ``` + */ + APP_ROOT: string + /** /dist/ or /public/ */ + VITE_PUBLIC: string + } +} + +// Used in Renderer process, expose in `preload.ts` +interface Window { + ipcRenderer: import('electron').IpcRenderer +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts new file mode 100644 index 00000000..302852f4 --- /dev/null +++ b/apps/desktop/electron/main.ts @@ -0,0 +1,66 @@ +import { app, BrowserWindow } from 'electron' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +// The built directory structure +// +// ├─┬─┬ dist +// │ │ └── index.html +// │ │ +// │ ├─┬ dist-electron +// │ │ ├── main.js +// │ │ └── preload.mjs +// │ +process.env.APP_ROOT = path.join(__dirname, '..') + +// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x +export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] +export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron') +export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist') + +process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST + +let win: BrowserWindow | null + +function createWindow() { + win = new BrowserWindow({ + icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'), + webPreferences: { + preload: path.join(__dirname, 'preload.mjs'), + }, + }) + + // Test active push message to Renderer-process. + win.webContents.on('did-finish-load', () => { + win?.webContents.send('main-process-message', (new Date).toLocaleString()) + }) + + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL) + } else { + // win.loadFile('dist/index.html') + win.loadFile(path.join(RENDERER_DIST, 'index.html')) + } +} + +// Quit when all windows are closed, except on macOS. There, it's common +// for applications and their menu bar to stay active until the user quits +// explicitly with Cmd + Q. +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit() + win = null + } +}) + +app.on('activate', () => { + // On OS X it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + if (BrowserWindow.getAllWindows().length === 0) { + createWindow() + } +}) + +app.whenReady().then(createWindow) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts new file mode 100644 index 00000000..05e341de --- /dev/null +++ b/apps/desktop/electron/preload.ts @@ -0,0 +1,24 @@ +import { ipcRenderer, contextBridge } from 'electron' + +// --------- Expose some API to the Renderer process --------- +contextBridge.exposeInMainWorld('ipcRenderer', { + on(...args: Parameters) { + const [channel, listener] = args + return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args)) + }, + off(...args: Parameters) { + const [channel, ...omit] = args + return ipcRenderer.off(channel, ...omit) + }, + send(...args: Parameters) { + const [channel, ...omit] = args + return ipcRenderer.send(channel, ...omit) + }, + invoke(...args: Parameters) { + const [channel, ...omit] = args + return ipcRenderer.invoke(channel, ...omit) + }, + + // You can expose other APTs you need here. + // ... +}) diff --git a/apps/desktop/index.html b/apps/desktop/index.html new file mode 100644 index 00000000..1136ddeb --- /dev/null +++ b/apps/desktop/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..8eb3132b --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,36 @@ +{ + "name": "@multica/desktop", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build && electron-builder", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@multica/ui": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.18", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@typescript-eslint/eslint-plugin": "^7.1.1", + "@typescript-eslint/parser": "^7.1.1", + "@vitejs/plugin-react": "^4.2.1", + "electron": "^30.0.1", + "electron-builder": "^24.13.3", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "tailwindcss": "^4", + "typescript": "catalog:", + "vite": "^5.1.6", + "vite-plugin-electron": "^0.28.6", + "vite-plugin-electron-renderer": "^0.14.5" + }, + "main": "dist-electron/main.js" +} diff --git a/apps/desktop/public/electron-vite.animate.svg b/apps/desktop/public/electron-vite.animate.svg new file mode 100644 index 00000000..ea3e7770 --- /dev/null +++ b/apps/desktop/public/electron-vite.animate.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/public/electron-vite.svg b/apps/desktop/public/electron-vite.svg new file mode 100644 index 00000000..8a6aefe6 --- /dev/null +++ b/apps/desktop/public/electron-vite.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop/public/vite.svg b/apps/desktop/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/apps/desktop/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx new file mode 100644 index 00000000..fc7cead5 --- /dev/null +++ b/apps/desktop/src/App.tsx @@ -0,0 +1,7 @@ +import { ComponentExample } from '@multica/ui/components/component-example' + +function App() { + return +} + +export default App diff --git a/apps/desktop/src/assets/react.svg b/apps/desktop/src/assets/react.svg new file mode 100644 index 00000000..6c87de9b --- /dev/null +++ b/apps/desktop/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx new file mode 100644 index 00000000..ad3387ad --- /dev/null +++ b/apps/desktop/src/main.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.tsx' +import "@multica/ui/globals.css" + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) + +// Use contextBridge +window.ipcRenderer.on('main-process-message', (_event, message) => { + console.log(message) +}) diff --git a/apps/desktop/src/vite-env.d.ts b/apps/desktop/src/vite-env.d.ts new file mode 100644 index 00000000..7d0ff9ef --- /dev/null +++ b/apps/desktop/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 00000000..cda7ec8f --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@multica/ui/*": ["../../packages/ui/src/*"], + "@multica/store/*": ["../../packages/store/src/*"] + }, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "electron"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json new file mode 100644 index 00000000..b8505820 --- /dev/null +++ b/apps/desktop/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts new file mode 100644 index 00000000..6651f566 --- /dev/null +++ b/apps/desktop/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite' +import path from 'node:path' +import electron from 'vite-plugin-electron/simple' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react(), + tailwindcss(), + electron({ + main: { + entry: 'electron/main.ts', + }, + preload: { + input: path.join(__dirname, 'electron/preload.ts'), + }, + renderer: process.env.NODE_ENV === 'test' + ? undefined + : {}, + }), + ], +}) diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f05f7486..8c345796 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono, Inter } from "next/font/google"; -import "./globals.css"; +import "@multica/ui/globals.css"; const inter = Inter({subsets:['latin'],variable:'--font-sans'}); diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 14115273..5e476424 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,5 +1,7 @@ -import { ComponentExample } from "@/components/component-example"; +"use client" + +import { ComponentExample } from "@multica/ui/components/component-example"; export default function Page() { -return ; + return ; } \ No newline at end of file diff --git a/apps/web/components.json b/apps/web/components.json index 1800a5b3..38168ef0 100644 --- a/apps/web/components.json +++ b/apps/web/components.json @@ -5,20 +5,18 @@ "tsx": true, "tailwind": { "config": "", - "css": "app/globals.css", + "css": "../../packages/ui/src/styles/globals.css", "baseColor": "zinc", - "cssVariables": true, - "prefix": "" + "cssVariables": true }, "iconLibrary": "hugeicons", "aliases": { "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", + "hooks": "@/hooks", "lib": "@/lib", - "hooks": "@/hooks" + "utils": "@multica/ui/lib/utils", + "ui": "@multica/ui/components/ui" }, "menuColor": "default", - "menuAccent": "subtle", - "registries": {} + "menuAccent": "subtle" } diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index e9ffa308..1e258c7f 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + transpilePackages: ["@multica/ui", "@multica/store"], }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index ceb63448..c9cf44e9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,33 +3,25 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint" }, "dependencies": { - "@multica/sdk": "workspace:*", - "@base-ui/react": "^1.1.0", + "@multica/ui": "workspace:*", "@hugeicons/core-free-icons": "^3.1.1", "@hugeicons/react": "^1.1.4", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", "next": "16.1.6", - "react": "19.2.3", - "react-dom": "19.2.3", - "shadcn": "^3.7.0", - "tailwind-merge": "^3.4.0", - "tw-animate-css": "^1.4.0" + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", "eslint": "^9", "eslint-config-next": "16.1.6", - "tailwindcss": "^4", - "typescript": "^5" + "typescript": "catalog:" } } diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs index 61e36849..ad8a3df5 100644 --- a/apps/web/postcss.config.mjs +++ b/apps/web/postcss.config.mjs @@ -1,7 +1 @@ -const config = { - plugins: { - "@tailwindcss/postcss": {}, - }, -}; - -export default config; +export { default } from "@multica/ui/postcss.config"; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3a13f90a..7c326cd3 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -19,7 +19,9 @@ } ], "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "@multica/ui/*": ["../../packages/ui/src/*"], + "@multica/store/*": ["../../packages/store/src/*"] } }, "include": [ diff --git a/package.json b/package.json index d8094ac8..dd93b110 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev:gateway": "tsx --watch src/gateway/main.ts", "dev:console": "tsx --watch src/console/main.ts", "dev:web": "pnpm --filter @multica/web dev", + "dev:desktop": "pnpm --filter @multica/desktop dev", "build": "turbo build", "build:sdk": "pnpm --filter @multica/sdk build", "start": "node dist/index.js", @@ -22,12 +23,12 @@ "license": "ISC", "packageManager": "pnpm@10.16.1", "devDependencies": { - "@types/node": "^25.0.10", + "@types/node": "catalog:", "@types/turndown": "^5.0.6", "@types/uuid": "^11.0.0", "tsx": "^4.21.0", "turbo": "^2.3.4", - "typescript": "^5.9.3" + "typescript": "catalog:" }, "dependencies": { "@mariozechner/pi-agent-core": "^0.50.3", diff --git a/packages/store/package.json b/packages/store/package.json new file mode 100644 index 00000000..2e9a547d --- /dev/null +++ b/packages/store/package.json @@ -0,0 +1,15 @@ +{ + "name": "@multica/store", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "dependencies": { + "zustand": "catalog:" + }, + "devDependencies": { + "typescript": "catalog:" + } +} diff --git a/packages/store/src/counter.ts b/packages/store/src/counter.ts new file mode 100644 index 00000000..30ffc733 --- /dev/null +++ b/packages/store/src/counter.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand' + +interface CounterState { + count: number + increment: () => void + decrement: () => void + reset: () => void +} + +export const useCounterStore = create((set) => ({ + count: 0, + increment: () => set((state) => ({ count: state.count + 1 })), + decrement: () => set((state) => ({ count: state.count - 1 })), + reset: () => set({ count: 0 }), +})) diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts new file mode 100644 index 00000000..778a5959 --- /dev/null +++ b/packages/store/src/index.ts @@ -0,0 +1 @@ +export { useCounterStore } from './counter' diff --git a/packages/store/tsconfig.json b/packages/store/tsconfig.json new file mode 100644 index 00000000..2fa65e5c --- /dev/null +++ b/packages/store/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": ".", + "paths": { + "@multica/store/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 00000000..b439442e --- /dev/null +++ b/packages/ui/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles/globals.css", + "baseColor": "zinc", + "cssVariables": true + }, + "iconLibrary": "hugeicons", + "aliases": { + "components": "@multica/ui/components", + "utils": "@multica/ui/lib/utils", + "hooks": "@multica/ui/hooks", + "lib": "@multica/ui/lib", + "ui": "@multica/ui/components/ui" + } +} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 00000000..17628c39 --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,34 @@ +{ + "name": "@multica/ui", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + "./globals.css": "./src/styles/globals.css", + "./postcss.config": "./postcss.config.mjs", + "./lib/*": "./src/lib/*.ts", + "./components/*": "./src/components/*.tsx", + "./components/ui/*": "./src/components/ui/*.tsx", + "./hooks/*": "./src/hooks/*.ts" + }, + "dependencies": { + "@multica/store": "workspace:*", + "@base-ui/react": "^1.1.0", + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "react": "catalog:", + "react-dom": "catalog:", + "shadcn": "^3.7.0", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4", + "tw-animate-css": "^1.4.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/ui/postcss.config.mjs b/packages/ui/postcss.config.mjs new file mode 100644 index 00000000..4ae682d8 --- /dev/null +++ b/packages/ui/postcss.config.mjs @@ -0,0 +1,6 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { "@tailwindcss/postcss": {} }, +}; + +export default config; diff --git a/apps/web/components/component-example.tsx b/packages/ui/src/components/component-example.tsx similarity index 91% rename from apps/web/components/component-example.tsx rename to packages/ui/src/components/component-example.tsx index 67a85d71..7d6fe331 100644 --- a/apps/web/components/component-example.tsx +++ b/packages/ui/src/components/component-example.tsx @@ -1,11 +1,9 @@ -"use client" - import * as React from "react" import { Example, ExampleWrapper, -} from "@/components/example" +} from "@multica/ui/components/example" import { AlertDialog, AlertDialogAction, @@ -17,9 +15,9 @@ import { AlertDialogMedia, AlertDialogTitle, AlertDialogTrigger, -} from "@/components/ui/alert-dialog" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" +} from "@multica/ui/components/ui/alert-dialog" +import { Badge } from "@multica/ui/components/ui/badge" +import { Button } from "@multica/ui/components/ui/button" import { Card, CardAction, @@ -28,7 +26,7 @@ import { CardFooter, CardHeader, CardTitle, -} from "@/components/ui/card" +} from "@multica/ui/components/ui/card" import { Combobox, ComboboxContent, @@ -36,7 +34,7 @@ import { ComboboxInput, ComboboxItem, ComboboxList, -} from "@/components/ui/combobox" +} from "@multica/ui/components/ui/combobox" import { DropdownMenu, DropdownMenuCheckboxItem, @@ -53,9 +51,9 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" -import { Input } from "@/components/ui/input" +} from "@multica/ui/components/ui/dropdown-menu" +import { Field, FieldGroup, FieldLabel } from "@multica/ui/components/ui/field" +import { Input } from "@multica/ui/components/ui/input" import { Select, SelectContent, @@ -63,20 +61,56 @@ import { SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" -import { Textarea } from "@/components/ui/textarea" +} from "@multica/ui/components/ui/select" +import { Textarea } from "@multica/ui/components/ui/textarea" import { HugeiconsIcon } from "@hugeicons/react" import { PlusSignIcon, BluetoothIcon, MoreVerticalCircle01Icon, FileIcon, FolderIcon, FolderOpenIcon, CodeIcon, MoreHorizontalCircle01Icon, SearchIcon, FloppyDiskIcon, DownloadIcon, EyeIcon, LayoutIcon, PaintBoardIcon, SunIcon, MoonIcon, ComputerIcon, UserIcon, CreditCardIcon, SettingsIcon, KeyboardIcon, LanguageCircleIcon, NotificationIcon, MailIcon, ShieldIcon, HelpCircleIcon, File01Icon, LogoutIcon } from "@hugeicons/core-free-icons" +import { useCounterStore } from "@multica/store/counter" export function ComponentExample() { return ( + ) } +function CounterExample() { + const { count, increment, decrement, reset } = useCounterStore() + + return ( + + + + Shared Counter + + This counter uses Zustand from @multica/store, shared across web and desktop. + + + +
+ + {count} + +
+
+ + + Count: {count} + +
+
+ ) +} + function CardExample() { return ( diff --git a/apps/web/components/example.tsx b/packages/ui/src/components/example.tsx similarity index 96% rename from apps/web/components/example.tsx rename to packages/ui/src/components/example.tsx index 78834926..6b8ff86a 100644 --- a/apps/web/components/example.tsx +++ b/packages/ui/src/components/example.tsx @@ -1,4 +1,4 @@ -import { cn } from "@/lib/utils" +import { cn } from "@multica/ui/lib/utils" function ExampleWrapper({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/apps/web/components/ui/alert-dialog.tsx b/packages/ui/src/components/ui/alert-dialog.tsx similarity index 96% rename from apps/web/components/ui/alert-dialog.tsx rename to packages/ui/src/components/ui/alert-dialog.tsx index ee099f84..2414ceb3 100644 --- a/apps/web/components/ui/alert-dialog.tsx +++ b/packages/ui/src/components/ui/alert-dialog.tsx @@ -3,8 +3,8 @@ import * as React from "react" import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog" -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" +import { cn } from "@multica/ui/lib/utils" +import { Button } from "@multica/ui/components/ui/button" function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { return @@ -148,7 +148,7 @@ function AlertDialogCancel({ size = "default", ...props }: AlertDialogPrimitive.Close.Props & - Pick, "variant" | "size">) { + Partial, "variant" | "size">>) { return ( svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>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 overflow-hidden group/badge", diff --git a/apps/web/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx similarity index 98% rename from apps/web/components/ui/button.tsx rename to packages/ui/src/components/ui/button.tsx index 03b4c4a0..23262118 100644 --- a/apps/web/components/ui/button.tsx +++ b/packages/ui/src/components/ui/button.tsx @@ -3,7 +3,7 @@ import { Button as ButtonPrimitive } from "@base-ui/react/button" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils" +import { cn } from "@multica/ui/lib/utils" const buttonVariants = cva( "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none", diff --git a/apps/web/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx similarity index 98% rename from apps/web/components/ui/card.tsx rename to packages/ui/src/components/ui/card.tsx index 84bfaa4d..82bbc93a 100644 --- a/apps/web/components/ui/card.tsx +++ b/packages/ui/src/components/ui/card.tsx @@ -1,6 +1,6 @@ import * as React from "react" -import { cn } from "@/lib/utils" +import { cn } from "@multica/ui/lib/utils" function Card({ className, diff --git a/apps/web/components/ui/combobox.tsx b/packages/ui/src/components/ui/combobox.tsx similarity index 98% rename from apps/web/components/ui/combobox.tsx rename to packages/ui/src/components/ui/combobox.tsx index 64b52f6f..e660471d 100644 --- a/apps/web/components/ui/combobox.tsx +++ b/packages/ui/src/components/ui/combobox.tsx @@ -3,14 +3,14 @@ import * as React from "react" import { Combobox as ComboboxPrimitive } from "@base-ui/react" -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" +import { cn } from "@multica/ui/lib/utils" +import { Button } from "@multica/ui/components/ui/button" import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, -} from "@/components/ui/input-group" +} from "@multica/ui/components/ui/input-group" import { HugeiconsIcon } from "@hugeicons/react" import { ArrowDown01Icon, Cancel01Icon, Tick02Icon } from "@hugeicons/core-free-icons" diff --git a/apps/web/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx similarity index 99% rename from apps/web/components/ui/dropdown-menu.tsx rename to packages/ui/src/components/ui/dropdown-menu.tsx index a2ee8492..158d0b27 100644 --- a/apps/web/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { Menu as MenuPrimitive } from "@base-ui/react/menu" -import { cn } from "@/lib/utils" +import { cn } from "@multica/ui/lib/utils" import { HugeiconsIcon } from "@hugeicons/react" import { ArrowRight01Icon, Tick02Icon } from "@hugeicons/core-free-icons" diff --git a/apps/web/components/ui/field.tsx b/packages/ui/src/components/ui/field.tsx similarity index 97% rename from apps/web/components/ui/field.tsx rename to packages/ui/src/components/ui/field.tsx index 5b613888..8a7af6da 100644 --- a/apps/web/components/ui/field.tsx +++ b/packages/ui/src/components/ui/field.tsx @@ -3,9 +3,9 @@ import { useMemo } from "react" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" -import { Separator } from "@/components/ui/separator" +import { cn } from "@multica/ui/lib/utils" +import { Label } from "@multica/ui/components/ui/label" +import { Separator } from "@multica/ui/components/ui/separator" function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { return ( diff --git a/apps/web/components/ui/input-group.tsx b/packages/ui/src/components/ui/input-group.tsx similarity index 95% rename from apps/web/components/ui/input-group.tsx rename to packages/ui/src/components/ui/input-group.tsx index 885676ee..16c431be 100644 --- a/apps/web/components/ui/input-group.tsx +++ b/packages/ui/src/components/ui/input-group.tsx @@ -3,10 +3,10 @@ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" +import { cn } from "@multica/ui/lib/utils" +import { Button } from "@multica/ui/components/ui/button" +import { Input } from "@multica/ui/components/ui/input" +import { Textarea } from "@multica/ui/components/ui/textarea" function InputGroup({ className, ...props }: React.ComponentProps<"div">) { return ( diff --git a/apps/web/components/ui/input.tsx b/packages/ui/src/components/ui/input.tsx similarity index 95% rename from apps/web/components/ui/input.tsx rename to packages/ui/src/components/ui/input.tsx index 56d1cd01..b45d66a7 100644 --- a/apps/web/components/ui/input.tsx +++ b/packages/ui/src/components/ui/input.tsx @@ -1,7 +1,7 @@ import * as React from "react" import { Input as InputPrimitive } from "@base-ui/react/input" -import { cn } from "@/lib/utils" +import { cn } from "@multica/ui/lib/utils" function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( diff --git a/apps/web/components/ui/label.tsx b/packages/ui/src/components/ui/label.tsx similarity index 91% rename from apps/web/components/ui/label.tsx rename to packages/ui/src/components/ui/label.tsx index 5ac813ec..2b3577d5 100644 --- a/apps/web/components/ui/label.tsx +++ b/packages/ui/src/components/ui/label.tsx @@ -2,7 +2,7 @@ import * as React from "react" -import { cn } from "@/lib/utils" +import { cn } from "@multica/ui/lib/utils" function Label({ className, ...props }: React.ComponentProps<"label">) { return ( diff --git a/apps/web/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx similarity index 99% rename from apps/web/components/ui/select.tsx rename to packages/ui/src/components/ui/select.tsx index c0cd0927..d52c2333 100644 --- a/apps/web/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { Select as SelectPrimitive } from "@base-ui/react/select" -import { cn } from "@/lib/utils" +import { cn } from "@multica/ui/lib/utils" import { HugeiconsIcon } from "@hugeicons/react" import { UnfoldMoreIcon, Tick02Icon, ArrowUp01Icon, ArrowDown01Icon } from "@hugeicons/core-free-icons" diff --git a/apps/web/components/ui/separator.tsx b/packages/ui/src/components/ui/separator.tsx similarity index 92% rename from apps/web/components/ui/separator.tsx rename to packages/ui/src/components/ui/separator.tsx index beca2503..24fbbb79 100644 --- a/apps/web/components/ui/separator.tsx +++ b/packages/ui/src/components/ui/separator.tsx @@ -2,7 +2,7 @@ import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" -import { cn } from "@/lib/utils" +import { cn } from "@multica/ui/lib/utils" function Separator({ className, diff --git a/packages/ui/src/components/ui/sheet.tsx b/packages/ui/src/components/ui/sheet.tsx new file mode 100644 index 00000000..69aea14e --- /dev/null +++ b/packages/ui/src/components/ui/sheet.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@multica/ui/lib/utils" +import { Button } from "@multica/ui/components/ui/button" +import { HugeiconsIcon } from "@hugeicons/react" +import { Cancel01Icon } from "@hugeicons/core-free-icons" + +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: SheetPrimitive.Description.Props) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/packages/ui/src/components/ui/sidebar.tsx b/packages/ui/src/components/ui/sidebar.tsx new file mode 100644 index 00000000..67b9f5c7 --- /dev/null +++ b/packages/ui/src/components/ui/sidebar.tsx @@ -0,0 +1,723 @@ +"use client" + +import * as React from "react" +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@multica/ui/lib/utils" +import { Button } from "@multica/ui/components/ui/button" +import { Input } from "@multica/ui/components/ui/input" +import { Separator } from "@multica/ui/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@multica/ui/components/ui/sheet" +import { Skeleton } from "@multica/ui/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@multica/ui/components/ui/tooltip" +import { useIsMobile } from "@multica/ui/hooks/use-mobile" +import { HugeiconsIcon } from "@hugeicons/react" +import { SidebarLeftIcon } from "@hugeicons/core-free-icons" + +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 ( +