diff --git a/.github/workflows/jan-electron-linter-and-test.yml b/.github/workflows/jan-electron-linter-and-test.yml index 828162c57..3a95e804e 100644 --- a/.github/workflows/jan-electron-linter-and-test.yml +++ b/.github/workflows/jan-electron-linter-and-test.yml @@ -6,17 +6,17 @@ on: - main - dev paths: - - "electron/**" + - 'electron/**' - .github/workflows/jan-electron-linter-and-test.yml - - "web/**" - - "uikit/**" - - "package.json" - - "node_modules/**" - - "yarn.lock" - - "core/**" - - "extensions/**" - - "!README.md" - - "Makefile" + - 'web/**' + - 'joi/**' + - 'package.json' + - 'node_modules/**' + - 'yarn.lock' + - 'core/**' + - 'extensions/**' + - '!README.md' + - 'Makefile' pull_request: branches: @@ -24,17 +24,17 @@ on: - dev - release/** paths: - - "electron/**" + - 'electron/**' - .github/workflows/jan-electron-linter-and-test.yml - - "web/**" - - "uikit/**" - - "package.json" - - "node_modules/**" - - "yarn.lock" - - "Makefile" - - "extensions/**" - - "core/**" - - "!README.md" + - 'web/**' + - 'joi/**' + - 'package.json' + - 'node_modules/**' + - 'yarn.lock' + - 'Makefile' + - 'extensions/**' + - 'core/**' + - '!README.md' jobs: test-on-macos: @@ -51,23 +51,23 @@ jobs: with: node-version: 20 - - name: "Cleanup cache" + - name: 'Cleanup cache' continue-on-error: true run: | rm -rf ~/jan make clean - name: Get Commit Message for PR - if : github.event_name == 'pull_request' + if: github.event_name == 'pull_request' run: | echo "REPORT_PORTAL_DESCRIPTION=${{github.event.after}})" >> $GITHUB_ENV - name: Get Commit Message for push event - if : github.event_name == 'push' + if: github.event_name == 'push' run: | echo "REPORT_PORTAL_DESCRIPTION=${{github.sha}})" >> $GITHUB_ENV - - name: "Config report portal" + - name: 'Config report portal' run: | make update-playwright-config REPORT_PORTAL_URL=${{ secrets.REPORT_PORTAL_URL }} REPORT_PORTAL_API_KEY=${{ secrets.REPORT_PORTAL_API_KEY }} REPORT_PORTAL_PROJECT_NAME=${{ secrets.REPORT_PORTAL_PROJECT_NAME }} REPORT_PORTAL_LAUNCH_NAME="Jan App macos" REPORT_PORTAL_DESCRIPTION="${{env.REPORT_PORTAL_DESCRIPTION}}" @@ -77,10 +77,10 @@ jobs: yarn config set registry ${{ secrets.NPM_PROXY }} --global make test env: - CSC_IDENTITY_AUTO_DISCOVERY: "false" - TURBO_API: "${{ secrets.TURBO_API }}" - TURBO_TEAM: "macos" - TURBO_TOKEN: "${{ secrets.TURBO_TOKEN }}" + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + TURBO_API: '${{ secrets.TURBO_API }}' + TURBO_TEAM: 'macos' + TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}' test-on-macos-pr-target: if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository @@ -96,7 +96,7 @@ jobs: with: node-version: 20 - - name: "Cleanup cache" + - name: 'Cleanup cache' continue-on-error: true run: | rm -rf ~/jan @@ -108,14 +108,14 @@ jobs: yarn config set registry https://registry.npmjs.org --global make test env: - CSC_IDENTITY_AUTO_DISCOVERY: "false" + CSC_IDENTITY_AUTO_DISCOVERY: 'false' test-on-windows: if: github.event_name == 'push' strategy: fail-fast: false matrix: - antivirus-tools: ['mcafee', 'default-windows-security','bit-defender'] + antivirus-tools: ['mcafee', 'default-windows-security', 'bit-defender'] runs-on: windows-desktop-${{ matrix.antivirus-tools }} steps: - name: Getting the repo @@ -129,7 +129,7 @@ jobs: node-version: 20 # Clean cache, continue on error - - name: "Cleanup cache" + - name: 'Cleanup cache' shell: powershell continue-on-error: true run: | @@ -140,14 +140,14 @@ jobs: Write-Output "Folder does not exist." } make clean - + - name: Get Commit Message for push event - if : github.event_name == 'push' + if: github.event_name == 'push' shell: bash run: | echo "REPORT_PORTAL_DESCRIPTION=${{github.sha}}" >> $GITHUB_ENV - - name: "Config report portal" + - name: 'Config report portal' shell: bash run: | make update-playwright-config REPORT_PORTAL_URL=${{ secrets.REPORT_PORTAL_URL }} REPORT_PORTAL_API_KEY=${{ secrets.REPORT_PORTAL_API_KEY }} REPORT_PORTAL_PROJECT_NAME=${{ secrets.REPORT_PORTAL_PROJECT_NAME }} REPORT_PORTAL_LAUNCH_NAME="Jan App Windows ${{ matrix.antivirus-tools }}" REPORT_PORTAL_DESCRIPTION="${{env.REPORT_PORTAL_DESCRIPTION}}" @@ -159,9 +159,9 @@ jobs: yarn config set registry ${{ secrets.NPM_PROXY }} --global make test env: - TURBO_API: "${{ secrets.TURBO_API }}" - TURBO_TEAM: "windows" - TURBO_TOKEN: "${{ secrets.TURBO_TOKEN }}" + TURBO_API: '${{ secrets.TURBO_API }}' + TURBO_TEAM: 'windows' + TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}' test-on-windows-pr: if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) runs-on: windows-desktop-default-windows-security @@ -177,7 +177,7 @@ jobs: node-version: 20 # Clean cache, continue on error - - name: "Cleanup cache" + - name: 'Cleanup cache' shell: powershell continue-on-error: true run: | @@ -190,12 +190,12 @@ jobs: make clean - name: Get Commit Message for PR - if : github.event_name == 'pull_request' + if: github.event_name == 'pull_request' shell: bash run: | echo "REPORT_PORTAL_DESCRIPTION=${{github.event.after}}" >> $GITHUB_ENV - - name: "Config report portal" + - name: 'Config report portal' shell: bash run: | make update-playwright-config REPORT_PORTAL_URL=${{ secrets.REPORT_PORTAL_URL }} REPORT_PORTAL_API_KEY=${{ secrets.REPORT_PORTAL_API_KEY }} REPORT_PORTAL_PROJECT_NAME=${{ secrets.REPORT_PORTAL_PROJECT_NAME }} REPORT_PORTAL_LAUNCH_NAME="Jan App Windows" REPORT_PORTAL_DESCRIPTION="${{env.REPORT_PORTAL_DESCRIPTION}}" @@ -207,9 +207,9 @@ jobs: yarn config set registry ${{ secrets.NPM_PROXY }} --global make test env: - TURBO_API: "${{ secrets.TURBO_API }}" - TURBO_TEAM: "windows" - TURBO_TOKEN: "${{ secrets.TURBO_TOKEN }}" + TURBO_API: '${{ secrets.TURBO_API }}' + TURBO_TEAM: 'windows' + TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}' test-on-windows-pr-target: if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository @@ -226,7 +226,7 @@ jobs: node-version: 20 # Clean cache, continue on error - - name: "Cleanup cache" + - name: 'Cleanup cache' shell: powershell continue-on-error: true run: | @@ -245,7 +245,6 @@ jobs: yarn config set registry https://registry.npmjs.org --global make test - test-on-ubuntu: runs-on: [self-hosted, Linux, ubuntu-desktop] if: (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || github.event_name == 'push' || github.event_name == 'workflow_dispatch' @@ -260,23 +259,23 @@ jobs: with: node-version: 20 - - name: "Cleanup cache" + - name: 'Cleanup cache' continue-on-error: true run: | rm -rf ~/jan make clean - name: Get Commit Message for PR - if : github.event_name == 'pull_request' + if: github.event_name == 'pull_request' run: | echo "REPORT_PORTAL_DESCRIPTION=${{github.event.after}}" >> $GITHUB_ENV - name: Get Commit Message for push event - if : github.event_name == 'push' + if: github.event_name == 'push' run: | echo "REPORT_PORTAL_DESCRIPTION=${{github.sha}}" >> $GITHUB_ENV - - name: "Config report portal" + - name: 'Config report portal' shell: bash run: | make update-playwright-config REPORT_PORTAL_URL=${{ secrets.REPORT_PORTAL_URL }} REPORT_PORTAL_API_KEY=${{ secrets.REPORT_PORTAL_API_KEY }} REPORT_PORTAL_PROJECT_NAME=${{ secrets.REPORT_PORTAL_PROJECT_NAME }} REPORT_PORTAL_LAUNCH_NAME="Jan App Linux" REPORT_PORTAL_DESCRIPTION="${{env.REPORT_PORTAL_DESCRIPTION}}" @@ -289,9 +288,9 @@ jobs: yarn config set registry ${{ secrets.NPM_PROXY }} --global make test env: - TURBO_API: "${{ secrets.TURBO_API }}" - TURBO_TEAM: "linux" - TURBO_TOKEN: "${{ secrets.TURBO_TOKEN }}" + TURBO_API: '${{ secrets.TURBO_API }}' + TURBO_TEAM: 'linux' + TURBO_TOKEN: '${{ secrets.TURBO_TOKEN }}' test-on-ubuntu-pr-target: runs-on: [self-hosted, Linux, ubuntu-desktop] @@ -307,16 +306,16 @@ jobs: with: node-version: 20 - - name: "Cleanup cache" + - name: 'Cleanup cache' continue-on-error: true run: | rm -rf ~/jan make clean - + - name: Linter and test run: | export DISPLAY=$(w -h | awk 'NR==1 {print $2}') echo -e "Display ID: $DISPLAY" npm config set registry https://registry.npmjs.org --global yarn config set registry https://registry.npmjs.org --global - make test \ No newline at end of file + make test diff --git a/.gitignore b/.gitignore index 1a7be6867..0b6f98465 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ electron/renderer electron/models electron/docs electron/engines +electron/themes electron/playwright-report server/pre-install package-lock.json diff --git a/Dockerfile b/Dockerfile index dee423170..7fbbda2cf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,10 +39,10 @@ COPY --from=builder /app/docs/openapi ./docs/openapi/ COPY --from=builder /app/pre-install ./pre-install/ # Copy the package.json, yarn.lock, and output of web yarn space to leverage Docker cache -COPY --from=builder /app/uikit ./uikit/ +COPY --from=builder /app/joi ./joi/ COPY --from=builder /app/web ./web/ -RUN yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit build +RUN yarn workspace @janhq/joi install && yarn workspace @janhq/joi build RUN yarn workspace @janhq/web install RUN npm install -g serve@latest diff --git a/Dockerfile.gpu b/Dockerfile.gpu index 7adc1e02a..195a28d42 100644 --- a/Dockerfile.gpu +++ b/Dockerfile.gpu @@ -63,10 +63,10 @@ COPY --from=builder /app/docs/openapi ./docs/openapi/ COPY --from=builder /app/pre-install ./pre-install/ # Copy the package.json, yarn.lock, and output of web yarn space to leverage Docker cache -COPY --from=builder /app/uikit ./uikit/ +COPY --from=builder /app/joi ./joi/ COPY --from=builder /app/web ./web/ -RUN yarn workspace @janhq/uikit install && yarn workspace @janhq/uikit build +RUN yarn workspace @janhq/joi install && yarn workspace @janhq/joi build RUN yarn workspace @janhq/web install RUN npm install -g serve@latest diff --git a/Makefile b/Makefile index a05f14c51..204e1698f 100644 --- a/Makefile +++ b/Makefile @@ -11,15 +11,15 @@ all: @echo "Specify a target to run" # Builds the UI kit -build-uikit: +build-joi: ifeq ($(OS),Windows_NT) - cd uikit && yarn config set network-timeout 300000 && yarn install && yarn build + cd joi && yarn config set network-timeout 300000 && yarn install && yarn build else - cd uikit && yarn install && yarn build + cd joi && yarn install && yarn build endif # Installs yarn dependencies and builds core and extensions -install-and-build: build-uikit +install-and-build: build-joi ifeq ($(OS),Windows_NT) yarn config set network-timeout 300000 endif diff --git a/core/src/types/api/index.ts b/core/src/types/api/index.ts index fb0dc5b93..971d5555b 100644 --- a/core/src/types/api/index.ts +++ b/core/src/types/api/index.ts @@ -11,6 +11,13 @@ export enum NativeRoute { selectDirectory = 'selectDirectory', selectFiles = 'selectFiles', relaunch = 'relaunch', + setNativeThemeLight = 'setNativeThemeLight', + setNativeThemeDark = 'setNativeThemeDark', + + setMinimizeApp = 'setMinimizeApp', + setCloseApp = 'setCloseApp', + setMaximizeApp = 'setMaximizeApp', + showOpenMenu = 'showOpenMenu', hideQuickAskWindow = 'hideQuickAskWindow', sendQuickAskInput = 'sendQuickAskInput', diff --git a/core/src/types/model/modelEntity.ts b/core/src/types/model/modelEntity.ts index 7b2828b46..426b30846 100644 --- a/core/src/types/model/modelEntity.ts +++ b/core/src/types/model/modelEntity.ts @@ -15,11 +15,16 @@ export type ModelInfo = { */ export enum InferenceEngine { + anthropic = 'anthropic', + mistral = 'mistral', + martian = 'martian', + openrouter = 'openrouter', nitro = 'nitro', openai = 'openai', groq = 'groq', triton_trtllm = 'triton_trtllm', nitro_tensorrt_llm = 'nitro-tensorrt-llm', + cohere = 'cohere', } export type ModelArtifact = { diff --git a/core/src/types/setting/settingComponent.ts b/core/src/types/setting/settingComponent.ts index e2bf667bd..2eae4e16f 100644 --- a/core/src/types/setting/settingComponent.ts +++ b/core/src/types/setting/settingComponent.ts @@ -16,11 +16,16 @@ export type ControllerType = 'slider' | 'checkbox' | 'input' export type InputType = 'password' | 'text' | 'email' | 'number' | 'tel' | 'url' +const InputActions = ['unobscure', 'copy'] as const +export type InputActionsTuple = typeof InputActions +export type InputAction = InputActionsTuple[number] + export type InputComponentProps = { placeholder: string value: string type?: InputType textAlign?: 'left' | 'right' + inputActions?: InputAction[] } export type SliderComponentProps = { diff --git a/electron/handlers/native.ts b/electron/handlers/native.ts index 89bce15df..1bc815b41 100644 --- a/electron/handlers/native.ts +++ b/electron/handlers/native.ts @@ -1,4 +1,4 @@ -import { app, ipcMain, dialog, shell } from 'electron' +import { app, ipcMain, dialog, shell, nativeTheme, screen } from 'electron' import { join } from 'path' import { windowManager } from '../managers/window' import { @@ -10,7 +10,10 @@ import { NativeRoute, SelectFileProp, } from '@janhq/core/node' -import { SelectFileOption } from '@janhq/core/.' +import { SelectFileOption } from '@janhq/core' +import { menu } from '../utils/menu' + +const isMac = process.platform === 'darwin' export function handleAppIPCs() { /** @@ -22,6 +25,41 @@ export function handleAppIPCs() { shell.openPath(getJanDataFolderPath()) }) + /** + * Handles the "setNativeThemeLight" IPC message by setting the native theme source to "light". + * This will change the appearance of the app to the light theme. + */ + ipcMain.handle(NativeRoute.setNativeThemeLight, () => { + nativeTheme.themeSource = 'light' + }) + + ipcMain.handle(NativeRoute.setCloseApp, () => { + windowManager.mainWindow?.close() + }) + + ipcMain.handle(NativeRoute.setMinimizeApp, () => { + windowManager.mainWindow?.minimize() + }) + + ipcMain.handle(NativeRoute.setMaximizeApp, async () => { + if (windowManager.mainWindow?.isMaximized()) { + // const bounds = await getBounds() + // windowManager.mainWindow?.setSize(bounds.width, bounds.height) + // windowManager.mainWindow?.setPosition(Number(bounds.x), Number(bounds.y)) + windowManager.mainWindow.restore() + } else { + windowManager.mainWindow?.maximize() + } + }) + + /** + * Handles the "setNativeThemeDark" IPC message by setting the native theme source to "dark". + * This will change the appearance of the app to the dark theme. + */ + ipcMain.handle(NativeRoute.setNativeThemeDark, () => { + nativeTheme.themeSource = 'dark' + }) + /** * Opens a URL in the user's default browser. * @param _event - The IPC event object. @@ -136,6 +174,16 @@ export function handleAppIPCs() { } ) + ipcMain.handle(NativeRoute.showOpenMenu, function (e, args) { + if (!isMac && windowManager.mainWindow) { + menu.popup({ + window: windowManager.mainWindow, + x: args.x, + y: args.y, + }) + } + }) + ipcMain.handle( NativeRoute.hideMainWindow, async (): Promise => windowManager.hideMainWindow() diff --git a/electron/main.ts b/electron/main.ts index 9f0bd8393..6ce7f476a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -19,7 +19,7 @@ import { handleAppIPCs } from './handlers/native' **/ import { setupMenu } from './utils/menu' import { createUserSpace } from './utils/path' -import { migrateExtensions } from './utils/migration' +import { migrate } from './utils/migration' import { cleanUpAndQuit } from './utils/clean' import { setupExtensions } from './utils/extension' import { setupCore } from './utils/setup' @@ -79,7 +79,7 @@ app }) .then(setupCore) .then(createUserSpace) - .then(migrateExtensions) + .then(migrate) .then(setupExtensions) .then(setupMenu) .then(handleIPCs) diff --git a/electron/managers/mainWindowConfig.ts b/electron/managers/mainWindowConfig.ts index 990e78b85..25f0635f7 100644 --- a/electron/managers/mainWindowConfig.ts +++ b/electron/managers/mainWindowConfig.ts @@ -1,17 +1,17 @@ -const DEFAULT_WIDTH = 1200 const DEFAULT_MIN_WIDTH = 400 -const DEFAULT_HEIGHT = 800 export const mainWindowConfig: Electron.BrowserWindowConstructorOptions = { - width: DEFAULT_WIDTH, - minWidth: DEFAULT_MIN_WIDTH, - height: DEFAULT_HEIGHT, skipTaskbar: false, + minWidth: DEFAULT_MIN_WIDTH, show: true, + titleBarStyle: 'hidden', + vibrancy: 'fullscreen-ui', + visualEffectState: 'active', + backgroundMaterial: 'acrylic', + maximizable: false, + autoHideMenuBar: true, trafficLightPosition: { - x: 10, - y: 15, + x: 16, + y: 10, }, - titleBarStyle: 'hiddenInset', - vibrancy: 'sidebar', } diff --git a/electron/managers/window.ts b/electron/managers/window.ts index ab76bb94b..3d5107b28 100644 --- a/electron/managers/window.ts +++ b/electron/managers/window.ts @@ -2,12 +2,14 @@ import { BrowserWindow, app, shell } from 'electron' import { quickAskWindowConfig } from './quickAskWindowConfig' import { mainWindowConfig } from './mainWindowConfig' import { getAppConfigurations, AppEvent } from '@janhq/core/node' +import { getBounds, saveBounds } from '../utils/setup' /** * Manages the current window instance. */ // TODO: refactor this let isAppQuitting = false + class WindowManager { public mainWindow?: BrowserWindow private _quickAskWindow: BrowserWindow | undefined = undefined @@ -19,9 +21,15 @@ class WindowManager { * Creates a new window instance. * @returns The created window instance. */ - createMainWindow(preloadPath: string, startUrl: string) { + async createMainWindow(preloadPath: string, startUrl: string) { + const bounds = await getBounds() + this.mainWindow = new BrowserWindow({ ...mainWindowConfig, + width: bounds.width, + height: bounds.height, + x: bounds.x, + y: bounds.y, webPreferences: { nodeIntegration: true, preload: preloadPath, @@ -40,6 +48,14 @@ class WindowManager { } } + this.mainWindow.on('resized', () => { + saveBounds(this.mainWindow?.getBounds()) + }) + + this.mainWindow.on('moved', () => { + saveBounds(this.mainWindow?.getBounds()) + }) + /* Load frontend app to the window */ this.mainWindow.loadURL(startUrl) diff --git a/electron/package.json b/electron/package.json index 48b7eaee2..feaee5e16 100644 --- a/electron/package.json +++ b/electron/package.json @@ -14,15 +14,19 @@ "renderer/**/*", "build/**/*.{js,map}", "pre-install", + "themes", "docs/**/*", "scripts/**/*", - "icons/**/*" + "icons/**/*", + "themes" ], "asarUnpack": [ "pre-install", + "themes", "docs", "scripts", - "icons" + "icons", + "themes" ], "publish": [ { @@ -114,7 +118,7 @@ "@types/request": "^2.48.12", "@typescript-eslint/eslint-plugin": "^6.7.3", "@typescript-eslint/parser": "^6.7.3", - "electron": "28.0.0", + "electron": "30.0.6", "electron-builder": "^24.13.3", "electron-builder-squirrel-windows": "^24.13.3", "electron-devtools-installer": "^3.2.0", diff --git a/electron/utils/menu.ts b/electron/utils/menu.ts index 893907c48..3f838e5ca 100644 --- a/electron/utils/menu.ts +++ b/electron/utils/menu.ts @@ -112,7 +112,8 @@ const template: (Electron.MenuItemConstructorOptions | Electron.MenuItem)[] = [ }, ] +export const menu = Menu.buildFromTemplate(template) + export const setupMenu = () => { - const menu = Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) } diff --git a/electron/utils/migration.ts b/electron/utils/migration.ts index 399b362f4..defe0cebb 100644 --- a/electron/utils/migration.ts +++ b/electron/utils/migration.ts @@ -1,29 +1,87 @@ import { app } from 'electron' -import { rmdir } from 'fs' +import { join } from 'path' +import { + rmdirSync, + readFileSync, + existsSync, + mkdirSync, + readdirSync, + cpSync, + lstatSync, +} from 'fs' import Store from 'electron-store' -import { getJanExtensionsPath } from '@janhq/core/node' +import { + getJanExtensionsPath, + getJanDataFolderPath, + appResourcePath, +} from '@janhq/core/node' /** - * Migrates the extensions by deleting the `extensions` directory in the user data path. + * Migrates the extensions & themes. * If the `migrated_version` key in the `Store` object does not match the current app version, * the function deletes the `extensions` directory and sets the `migrated_version` key to the current app version. * @returns A Promise that resolves when the migration is complete. */ -export function migrateExtensions() { - return new Promise((resolve) => { - const store = new Store() - if (store.get('migrated_version') !== app.getVersion()) { - console.debug('start migration:', store.get('migrated_version')) +export async function migrate() { + const store = new Store() + if (store.get('migrated_version') !== app.getVersion()) { + console.debug('start migration:', store.get('migrated_version')) - rmdir(getJanExtensionsPath(), { recursive: true }, function (err) { - if (err) console.error(err) - store.set('migrated_version', app.getVersion()) - console.debug('migrate extensions done') - resolve(undefined) - }) - } else { - resolve(undefined) - } - }) + // if (existsSync(getJanExtensionsPath())) + // rmdirSync(getJanExtensionsPath(), { recursive: true }) + await migrateThemes() + + store.set('migrated_version', app.getVersion()) + console.debug('migrate extensions done') + } else if (!existsSync(join(getJanDataFolderPath(), 'themes'))) { + await migrateThemes() + } +} + +async function migrateThemes() { + if (!existsSync(join(getJanDataFolderPath(), 'themes'))) + mkdirSync(join(getJanDataFolderPath(), 'themes'), { recursive: true }) + + const themes = readdirSync(join(await appResourcePath(), 'themes')) + for (const theme of themes) { + const themePath = join(await appResourcePath(), 'themes', theme) + if (existsSync(themePath) && !lstatSync(themePath).isDirectory()) { + continue + } + await checkAndMigrateTheme(theme, themePath) + } +} + +async function checkAndMigrateTheme( + sourceThemeName: string, + sourceThemePath: string +) { + const janDataThemesFolder = join(getJanDataFolderPath(), 'themes') + const existingTheme = readdirSync(janDataThemesFolder).find( + (theme) => theme === sourceThemeName + ) + if (existingTheme) { + const desTheme = join(janDataThemesFolder, existingTheme) + if (!existsSync(desTheme) || !lstatSync(desTheme).isDirectory()) return + + const desThemeData = JSON.parse( + readFileSync(join(desTheme, 'theme.json'), 'utf-8') + ) + const sourceThemeData = JSON.parse( + readFileSync(join(sourceThemePath, 'theme.json'), 'utf-8') + ) + if (desThemeData.version !== sourceThemeData.version) { + console.debug('Updating theme', existingTheme) + rmdirSync(desTheme, { recursive: true }) + cpSync(sourceThemePath, join(janDataThemesFolder, sourceThemeName), { + recursive: true, + }) + } + } else { + console.debug('Adding new theme', sourceThemeName) + cpSync(sourceThemePath, join(janDataThemesFolder, sourceThemeName), { + recursive: true, + }) + } } diff --git a/electron/utils/setup.ts b/electron/utils/setup.ts index d60ab47bb..437e21f97 100644 --- a/electron/utils/setup.ts +++ b/electron/utils/setup.ts @@ -1,4 +1,10 @@ import { app } from 'electron' +import Store from 'electron-store' + +const DEFAULT_WIDTH = 1000 +const DEFAULT_HEIGHT = 800 + +const storage = new Store() export const setupCore = async () => { // Setup core api for main process @@ -7,3 +13,24 @@ export const setupCore = async () => { appPath: () => app.getPath('userData'), } } + +export const getBounds = async () => { + const defaultBounds = { + x: undefined, + y: undefined, + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + } + + const bounds = await storage.get('windowBounds') + if (bounds) { + return bounds as Electron.Rectangle + } else { + storage.set('windowBounds', defaultBounds) + return defaultBounds + } +} + +export const saveBounds = (bounds: Electron.Rectangle | undefined) => { + storage.set('windowBounds', bounds) +} diff --git a/extensions/inference-nitro-extension/bin/version.txt b/extensions/inference-nitro-extension/bin/version.txt index 76914ddc0..5f749c136 100644 --- a/extensions/inference-nitro-extension/bin/version.txt +++ b/extensions/inference-nitro-extension/bin/version.txt @@ -1 +1 @@ -0.4.9 +0.4.11 diff --git a/extensions/inference-nitro-extension/package.json b/extensions/inference-nitro-extension/package.json index 1903eafef..ce19734d2 100644 --- a/extensions/inference-nitro-extension/package.json +++ b/extensions/inference-nitro-extension/package.json @@ -1,7 +1,7 @@ { "name": "@janhq/inference-cortex-extension", "productName": "Cortex Inference Engine", - "version": "1.0.10", + "version": "1.0.11", "description": "This extension embeds cortex.cpp, a lightweight inference engine written in C++. See https://nitro.jan.ai.\nAdditional dependencies could be installed to run without Cuda Toolkit installation.", "main": "dist/index.js", "node": "dist/node/index.cjs.js", diff --git a/extensions/inference-nitro-extension/resources/models/codestral-22b/model.json b/extensions/inference-nitro-extension/resources/models/codestral-22b/model.json new file mode 100644 index 000000000..8e026e340 --- /dev/null +++ b/extensions/inference-nitro-extension/resources/models/codestral-22b/model.json @@ -0,0 +1,36 @@ +{ + "sources": [ + { + "filename": "Codestral-22B-v0.1-Q4_K_M.gguf", + "url": "https://huggingface.co/bartowski/Codestral-22B-v0.1-GGUF/resolve/main/Codestral-22B-v0.1-Q4_K_M.gguf" + } + ], + "id": "codestral-22b", + "object": "model", + "name": "Codestral 22B Q4", + "version": "1.0", + "description": "Latest model from MistralAI optimized for code generation tasks.", + "format": "gguf", + "settings": { + "ctx_len": 32000, + "prompt_template": "{system_message} [INST] {prompt} [/INST]", + "llama_model_path": "Codestral-22B-v0.1-Q4_K_M.gguf", + "ngl": 56 + }, + "parameters": { + "temperature": 0.7, + "top_p": 0.95, + "stream": true, + "max_tokens": 32000, + "stop": [", [/INST]"], + "frequency_penalty": 0, + "presence_penalty": 0 + }, + "metadata": { + "author": "MistralAI", + "tags": ["22B", "Finetuned", "Featured"], + "size": 13341237440 + }, + "engine": "nitro" + } + diff --git a/extensions/inference-nitro-extension/resources/models/mistral-ins-7b-q4/model.json b/extensions/inference-nitro-extension/resources/models/mistral-ins-7b-q4/model.json index c372aa329..21dcea865 100644 --- a/extensions/inference-nitro-extension/resources/models/mistral-ins-7b-q4/model.json +++ b/extensions/inference-nitro-extension/resources/models/mistral-ins-7b-q4/model.json @@ -1,20 +1,20 @@ { "sources": [ { - "filename": "mistral-7b-instruct-v0.2.Q4_K_M.gguf", - "url": "https://huggingface.co/TheBloke/Mistral-7B-Instruct-v0.2-GGUF/resolve/main/mistral-7b-instruct-v0.2.Q4_K_M.gguf" + "filename": "Mistral-7B-Instruct-v0.3-Q4_K_M.gguf", + "url": "https://huggingface.co/bartowski/Mistral-7B-Instruct-v0.3-GGUF/resolve/main/Mistral-7B-Instruct-v0.3-Q4_K_M.gguf" } ], "id": "mistral-ins-7b-q4", "object": "model", "name": "Mistral Instruct 7B Q4", - "version": "1.1", + "version": "1.2", "description": "Mistral Instruct 7b model, specifically designed for a comprehensive understanding of the world.", "format": "gguf", "settings": { "ctx_len": 32768, - "prompt_template": "[INST] {prompt} [/INST]", - "llama_model_path": "mistral-7b-instruct-v0.2.Q4_K_M.gguf", + "prompt_template": "{system_message} [INST] {prompt} [/INST]", + "llama_model_path": "Mistral-7B-Instruct-v0.3-Q4_K_M.gguf", "ngl": 32 }, "parameters": { diff --git a/extensions/inference-nitro-extension/resources/models/phi3-medium/model.json b/extensions/inference-nitro-extension/resources/models/phi3-medium/model.json index 63dda8f0a..8f5bfa1c3 100644 --- a/extensions/inference-nitro-extension/resources/models/phi3-medium/model.json +++ b/extensions/inference-nitro-extension/resources/models/phi3-medium/model.json @@ -8,7 +8,7 @@ "id": "phi3-medium", "object": "model", "name": "Phi-3 Medium", - "version": "1.0", + "version": "1.1", "description": "Phi-3 Medium is Microsoft's latest SOTA model.", "format": "gguf", "settings": { @@ -29,7 +29,7 @@ "metadata": { "author": "Microsoft", "tags": [ - "7B", + "14B", "Finetuned" ], "size": 8366000000 diff --git a/extensions/inference-nitro-extension/rollup.config.ts b/extensions/inference-nitro-extension/rollup.config.ts index c28d5b64e..3a790b501 100644 --- a/extensions/inference-nitro-extension/rollup.config.ts +++ b/extensions/inference-nitro-extension/rollup.config.ts @@ -38,6 +38,7 @@ const llama3Hermes8bJson = require('./resources/models/llama3-hermes-8b/model.js const aya8bJson = require('./resources/models/aya-23-8b/model.json') const aya35bJson = require('./resources/models/aya-23-35b/model.json') const phimediumJson = require('./resources/models/phi3-medium/model.json') +const codestralJson = require('./resources/models/codestral-22b/model.json') export default [ { @@ -82,7 +83,8 @@ export default [ llama3Hermes8bJson, phimediumJson, aya8bJson, - aya35bJson + aya35bJson, + codestralJson ]), NODE: JSON.stringify(`${packageJson.name}/${packageJson.node}`), DEFAULT_SETTINGS: JSON.stringify(defaultSettingJson), diff --git a/extensions/model-extension/resources/default-model.json b/extensions/model-extension/resources/default-model.json index f2e15d2c9..c02008cd6 100644 --- a/extensions/model-extension/resources/default-model.json +++ b/extensions/model-extension/resources/default-model.json @@ -23,7 +23,7 @@ "top_p": 0.95, "stream": true, "max_tokens": 2048, - "stop": [""], + "stop": ["<|END_OF_TURN_TOKEN|>", "", "[/INST]", "<|end_of_text|>", "<|eot_id|>", "<|im_end|>", "<|end|>"], "frequency_penalty": 0, "presence_penalty": 0 }, diff --git a/extensions/model-extension/resources/settings.json b/extensions/model-extension/resources/settings.json new file mode 100644 index 000000000..d896f1271 --- /dev/null +++ b/extensions/model-extension/resources/settings.json @@ -0,0 +1,14 @@ +[ + { + "key": "hugging-face-access-token", + "title": "Hugging Face Access Token", + "description": "Access tokens programmatically authenticate your identity to the Hugging Face Hub, allowing applications to perform specific actions specified by the scope of permissions granted.", + "controllerType": "input", + "controllerProps": { + "value": "", + "placeholder": "hf_**********************************", + "type": "password", + "inputActions": ["unobscure", "copy"] + } + } +] diff --git a/extensions/model-extension/rollup.config.ts b/extensions/model-extension/rollup.config.ts index abd12890e..aa22bd1f6 100644 --- a/extensions/model-extension/rollup.config.ts +++ b/extensions/model-extension/rollup.config.ts @@ -4,6 +4,7 @@ import typescript from 'rollup-plugin-typescript2' import json from '@rollup/plugin-json' import replace from '@rollup/plugin-replace' +const settingJson = require('./resources/settings.json') const packageJson = require('./package.json') const defaultModelJson = require('./resources/default-model.json') @@ -20,6 +21,7 @@ export default [ replace({ preventAssignment: true, DEFAULT_MODEL: JSON.stringify(defaultModelJson), + SETTINGS: JSON.stringify(settingJson), NODE: JSON.stringify(`${packageJson.name}/${packageJson.node}`), }), // Allow json resolution diff --git a/extensions/model-extension/src/index.ts b/extensions/model-extension/src/index.ts index dbe7605ea..887ce7474 100644 --- a/extensions/model-extension/src/index.ts +++ b/extensions/model-extension/src/index.ts @@ -31,6 +31,11 @@ import { GGUFMetadata, gguf } from '@huggingface/gguf' import { NotSupportedModelError } from './@types/NotSupportModelError' import { InvalidHostError } from './@types/InvalidHostError' +declare const SETTINGS: Array +enum Settings { + huggingFaceAccessToken = 'hugging-face-access-token', +} + /** * A extension for models */ @@ -63,6 +68,7 @@ export default class JanModelExtension extends ModelExtension { */ async onLoad() { // Handle Desktop Events + this.registerSettings(SETTINGS) this.handleDesktopEvents() } @@ -195,7 +201,21 @@ export default class JanModelExtension extends ModelExtension { const sanitizedUrl = this.toHuggingFaceUrl(repoId) console.debug('sanitizedUrl', sanitizedUrl) - const res = await fetch(sanitizedUrl) + const huggingFaceAccessToken = ( + await this.getSetting(Settings.huggingFaceAccessToken, '') + ).trim() + + const headers = { + Accept: 'application/json', + } + + if (huggingFaceAccessToken.length > 0) { + headers['Authorization'] = `Bearer ${huggingFaceAccessToken}` + } + + const res = await fetch(sanitizedUrl, { + headers: headers, + }) const response = await res.json() if (response['error'] != null) { throw new Error(response['error']) diff --git a/uikit/.prettierignore b/joi/.prettierignore similarity index 75% rename from uikit/.prettierignore rename to joi/.prettierignore index 02d9145c1..e9e840d7e 100644 --- a/uikit/.prettierignore +++ b/joi/.prettierignore @@ -2,4 +2,5 @@ node_modules/ dist/ *.hbs -*.mdx \ No newline at end of file +*.mdx +*.mjs \ No newline at end of file diff --git a/uikit/.prettierrc b/joi/.prettierrc similarity index 100% rename from uikit/.prettierrc rename to joi/.prettierrc diff --git a/joi/README.md b/joi/README.md new file mode 100644 index 000000000..161db4156 --- /dev/null +++ b/joi/README.md @@ -0,0 +1,13 @@ +# @janhq/joi + +To install dependencies: + +```bash +yarn install +``` + +To run: + +```bash +yarn run dev +``` diff --git a/joi/package.json b/joi/package.json new file mode 100644 index 000000000..3f1bd07f7 --- /dev/null +++ b/joi/package.json @@ -0,0 +1,59 @@ +{ + "name": "@janhq/joi", + "version": "0.0.0", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/index.d.ts", + "description": "A collection of UI component", + "private": true, + "files": [ + "dist" + ], + "keywords": [ + "design-system" + ], + "license": "MIT", + "homepage": "https://github.com/codecentrum/piksel#readme", + "repository": { + "type": "git", + "url": "https://github.com/codecentrum/piksel.git" + }, + "bugs": "https://github.com/codecentrum/piksel/issues", + "scripts": { + "dev": "rollup -c -w", + "build": "rimraf ./dist && rollup -c" + }, + "peerDependencies": { + "class-variance-authority": "^0.7.0", + "react": "^18", + "typescript": "^5.0.0" + }, + "dependencies": { + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", + "tailwind-merge": "^2.2.0", + "autoprefixer": "10.4.16", + "tailwindcss": "^3.4.1" + }, + "devDependencies": { + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-terser": "^0.4.4", + "prettier": "^3.0.3", + "prettier-plugin-tailwindcss": "^0.5.6", + "rollup": "^4.12.0", + "rollup-plugin-bundle-size": "^1.0.3", + "rollup-plugin-commonjs": "^10.1.0", + "rollup-plugin-copy": "^3.5.0", + "rollup-plugin-dts": "^6.1.0", + "rollup-plugin-peer-deps-external": "^2.2.4", + "rollup-plugin-postcss": "^4.0.2", + "rollup-plugin-typescript2": "^0.36.0" + } +} diff --git a/joi/rollup.config.mjs b/joi/rollup.config.mjs new file mode 100644 index 000000000..333a61c5c --- /dev/null +++ b/joi/rollup.config.mjs @@ -0,0 +1,73 @@ +import { readFileSync } from 'fs' +import dts from 'rollup-plugin-dts' +import terser from '@rollup/plugin-terser' +import autoprefixer from 'autoprefixer' +import commonjs from 'rollup-plugin-commonjs' +import bundleSize from 'rollup-plugin-bundle-size' +import peerDepsExternal from 'rollup-plugin-peer-deps-external' +import postcss from 'rollup-plugin-postcss' +import typescript from 'rollup-plugin-typescript2' +import tailwindcss from 'tailwindcss' +import typescriptEngine from 'typescript' +import resolve from '@rollup/plugin-node-resolve' +import copy from 'rollup-plugin-copy' + +const packageJson = JSON.parse(readFileSync('./package.json')) + +import tailwindConfig from './tailwind.config.js' + +export default [ + { + input: `./src/index.ts`, + output: [ + { + file: packageJson.main, + format: 'cjs', + sourcemap: false, + exports: 'named', + name: packageJson.name, + }, + { + file: packageJson.module, + format: 'es', + exports: 'named', + sourcemap: false, + }, + ], + plugins: [ + postcss({ + plugins: [autoprefixer(), tailwindcss(tailwindConfig)], + sourceMap: true, + use: ['sass'], + minimize: true, + extract: 'main.css', + }), + peerDepsExternal({ includeDependencies: true }), + resolve(), + commonjs(), + typescript({ + tsconfig: './tsconfig.json', + typescript: typescriptEngine, + sourceMap: false, + exclude: ['docs', 'dist', 'node_modules/**'], + }), + terser(), + ], + watch: { + clearScreen: false, + }, + }, + { + input: 'dist/esm/index.d.ts', + output: [{ file: 'dist/index.d.ts', format: 'esm' }], + external: [/\.(sc|sa|c)ss$/], + plugins: [ + dts(), + peerDepsExternal({ includeDependencies: true }), + copy({ + targets: [{ src: 'dist/esm/main.css', dest: 'dist' }], + }), + bundleSize(), + ], + }, +] diff --git a/joi/src/core/Accordion/index.tsx b/joi/src/core/Accordion/index.tsx new file mode 100644 index 000000000..75a671ca4 --- /dev/null +++ b/joi/src/core/Accordion/index.tsx @@ -0,0 +1,45 @@ +import React, { ReactNode } from 'react' +import * as AccordionPrimitive from '@radix-ui/react-accordion' + +import { ChevronDownIcon } from '@radix-ui/react-icons' + +import './styles.scss' + +type AccordionProps = { + defaultValue: string[] + children: ReactNode +} + +type AccordionItemProps = { + children: ReactNode + value: string + title: string +} + +const AccordionItem = ({ children, value, title }: AccordionItemProps) => { + return ( + + + +
{title}
+ +
+
+ +
{children}
+
+
+ ) +} + +const Accordion = ({ defaultValue, children }: AccordionProps) => ( + + {children} + +) + +export { Accordion, AccordionItem } diff --git a/joi/src/core/Accordion/styles.scss b/joi/src/core/Accordion/styles.scss new file mode 100644 index 000000000..028cc021c --- /dev/null +++ b/joi/src/core/Accordion/styles.scss @@ -0,0 +1,73 @@ +.accordion { + border-top: 1px solid hsla(var(--app-border)); + + &__item { + overflow: hidden; + margin-top: 1px; + border-bottom: 1px solid hsla(var(--app-border)); + + :focus-within { + position: relative; + z-index: 1; + } + } + + &__header { + display: flex; + } + + &__trigger { + font-family: inherit; + background-color: transparent; + padding: 0 16px; + height: 40px; + flex: 1; + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 500; + } + + &__content { + overflow: hidden; + + &--wrapper { + padding: 4px 16px 16px 16px; + } + } + + &__chevron { + color: hsla(var(--text-secondary)); + transition: transform 300ms cubic-bezier(0.87, 0, 0.13, 1); + } +} + +.accordion__content[data-state='open'] { + animation: slideDown 300ms cubic-bezier(0.87, 0, 0.13, 1); +} + +.accordion__content[data-state='closed'] { + animation: slideUp 300ms cubic-bezier(0.87, 0, 0.13, 1); +} + +.accordion__trigger[data-state='open'] > .accordion__chevron { + transform: rotate(180deg); +} + +@keyframes slideDown { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } +} + +@keyframes slideUp { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } +} diff --git a/joi/src/core/Badge/index.tsx b/joi/src/core/Badge/index.tsx new file mode 100644 index 000000000..ffc34624f --- /dev/null +++ b/joi/src/core/Badge/index.tsx @@ -0,0 +1,50 @@ +import React, { HTMLAttributes } from 'react' + +import { cva, type VariantProps } from 'class-variance-authority' + +import { twMerge } from 'tailwind-merge' + +import './styles.scss' + +const badgeVariants = cva('badge', { + variants: { + theme: { + primary: 'badge--primary', + secondary: 'badge--secondary', + warning: 'badge--warning', + success: 'badge--success', + info: 'badge--info', + destructive: 'badge--destructive', + }, + variant: { + solid: 'badge--solid', + soft: 'badge--soft', + outline: 'badge--outline', + }, + size: { + small: 'badge--small', + medium: 'badge--medium', + large: 'badge--large', + }, + }, + defaultVariants: { + theme: 'primary', + size: 'medium', + variant: 'solid', + }, +}) + +export interface BadgeProps + extends HTMLAttributes, + VariantProps {} + +const Badge = ({ className, theme, size, variant, ...props }: BadgeProps) => { + return ( +
+ ) +} + +export { Badge } diff --git a/joi/src/core/Badge/styles.scss b/joi/src/core/Badge/styles.scss new file mode 100644 index 000000000..a912e9216 --- /dev/null +++ b/joi/src/core/Badge/styles.scss @@ -0,0 +1,131 @@ +.badge { + @apply inline-flex items-center justify-center px-2 font-medium transition-all; + + // Primary + &--primary { + color: hsla(var(--primary-fg)); + background-color: hsla(var(--primary-bg)); + + // Variant soft primary + &.badge--soft { + background-color: hsla(var(--primary-bg-soft)); + color: hsla(var(--primary-bg)); + } + + // Variant outline primary + &.badge--outline { + background-color: transparent; + border: 1px solid hsla(var(--primary-bg)); + color: hsla(var(--primary-bg)); + } + } + + // Secondary + &--secondary { + background-color: hsla(var(--secondary-bg)); + color: hsla(var(--secondary-fg)); + + &.badge--soft { + background-color: hsla(var(--secondary-bg-soft)); + color: hsla(var(--secondary-bg)); + } + + // Variant outline secondary + &.badge--outline { + background-color: transparent; + border: 1px solid hsla(var(--secondary-bg)); + } + } + + // Destructive + &--destructive { + color: hsla(var(--destructive-fg)); + background-color: hsla(var(--destructive-bg)); + + // Variant soft destructive + &.badge--soft { + background-color: hsla(var(--destructive-bg-soft)); + color: hsla(var(--destructive-bg)); + } + + // Variant outline destructive + &.badge--outline { + background-color: transparent; + border: 1px solid hsla(var(--destructive-bg)); + color: hsla(var(--destructive-bg)); + } + } + + // Success + &--success { + @apply text-white; + background-color: hsla(var(--success-bg)); + + // Variant soft success + &.badge--soft { + background-color: hsla(var(--success-bg-soft)); + color: hsla(var(--success-bg)); + } + + // Variant outline success + &.badge--outline { + background-color: transparent; + border: 1px solid hsla(var(--success-bg)); + color: hsla(var(--success-bg)); + } + } + + // Warning + &--warning { + @apply text-white; + background-color: hsla(var(--warning-bg)); + + // Variant soft warning + &.badge--soft { + background-color: hsla(var(--warning-bg-soft)); + color: hsla(var(--warning-bg)); + } + + // Variant outline warning + &.badge--outline { + background-color: transparent; + border: 1px solid hsla(var(--warning-bg)); + color: hsla(var(--warning-bg)); + } + } + + // Info + &--info { + @apply text-white; + background-color: hsla(var(--info-bg)); + + // Variant soft info + &.badge--soft { + background-color: hsla(var(--info-bg-soft)); + color: hsla(var(--info-bg)); + } + + // Variant outline info + &.badge--outline { + background-color: transparent; + border: 1px solid hsla(var(--info-bg)); + color: hsla(var(--info-bg)); + } + } + + // Size + &--small { + @apply h-5; + border-radius: 4px; + } + + &--medium { + @apply h-6; + border-radius: 6px; + } + + &--large { + @apply h-7; + border-radius: 8px; + } +} diff --git a/joi/src/core/Button/index.tsx b/joi/src/core/Button/index.tsx new file mode 100644 index 000000000..014f534b0 --- /dev/null +++ b/joi/src/core/Button/index.tsx @@ -0,0 +1,64 @@ +import React, { forwardRef, ButtonHTMLAttributes } from 'react' + +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { twMerge } from 'tailwind-merge' + +import './styles.scss' + +const buttonVariants = cva('btn', { + variants: { + theme: { + primary: 'btn--primary', + ghost: 'btn--ghost', + icon: 'btn--icon', + destructive: 'btn--destructive', + }, + variant: { + solid: 'btn--solid', + soft: 'btn--soft', + outline: 'btn--outline', + }, + size: { + small: 'btn--small', + medium: 'btn--medium', + large: 'btn--large', + }, + block: { + true: 'btn--block', + }, + }, + defaultVariants: { + theme: 'primary', + size: 'medium', + variant: 'solid', + block: false, + }, +}) + +export interface ButtonProps + extends ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = forwardRef( + ( + { className, theme, size, variant, block, asChild = false, ...props }, + ref + ) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + } +) + +export { Button } diff --git a/joi/src/core/Button/styles.scss b/joi/src/core/Button/styles.scss new file mode 100644 index 000000000..f7cdce6a4 --- /dev/null +++ b/joi/src/core/Button/styles.scss @@ -0,0 +1,134 @@ +.btn { + @apply inline-flex items-center justify-center px-4 font-semibold transition-all; + + &:focus, + &:focus-within { + @apply outline-2 outline-offset-4; + } + &:hover { + filter: brightness(95%); + } + + // Primary + &--primary { + color: hsla(var(--primary-fg)); + background-color: hsla(var(--primary-bg)) !important; + &:hover { + filter: brightness(65%); + } + + // Variant soft primary + &.btn--soft { + background-color: hsla(var(--primary-bg-soft)) !important; + color: hsla(var(--primary-bg)); + } + + // Variant outline primary + &.btn--outline { + background-color: transparent !important; + border: 1px solid hsla(var(--primary-bg)); + color: hsla(var(--primary-bg)); + } + } + + // Ghost + &--ghost { + background-color: transparent !important; + &.btn--soft { + background-color: transparent !important; + } + + // Variant outline ghost + &.btn--outline { + background-color: transparent !important; + border: 1px solid hsla(var(--ghost-border)); + } + } + + // Destructive + &--destructive { + color: hsla(var(--destructive-fg)); + background-color: hsla(var(--destructive-bg)) !important; + &:hover { + filter: brightness(65%); + } + + // Variant soft destructive + &.btn--soft { + background-color: hsla(var(--destructive-bg-soft)) !important; + color: hsla(var(--destructive-bg)); + } + + // Variant outline destructive + &.btn--outline { + background-color: transparent !important; + border: 1px solid hsla(var(--destructive-bg)); + color: hsla(var(--destructive-bg)); + } + } + + // Disabled + &:disabled { + color: hsla(var(--disabled-fg)); + background-color: hsla(var(--disabled-bg)) !important; + cursor: not-allowed; + + &:hover { + filter: brightness(100%); + } + } + + // Icon + &--icon { + width: 24px; + height: 24px; + padding: 2px; + &:hover { + background-color: hsla(var(--icon-bg)) !important; + } + + &.btn--outline { + background-color: transparent !important; + border: 1px solid hsla(var(--icon-border)); + &:hover { + background-color: hsla(var(--icon-bg)) !important; + } + } + } + + // Size + &--small { + @apply h-6 px-2; + font-size: 12px; + border-radius: 4px; + &.btn--icon { + width: 24px; + height: 24px; + padding: 2px; + } + } + + &--medium { + @apply h-8; + border-radius: 6px; + &.btn--icon { + width: 24px; + height: 24px; + padding: 2px; + } + } + + &--large { + @apply h-9; + border-radius: 8px; + &.btn--icon { + width: 24px; + height: 24px; + padding: 2px; + } + } + + &--block { + @apply w-full; + } +} diff --git a/joi/src/core/Checkbox/index.tsx b/joi/src/core/Checkbox/index.tsx new file mode 100644 index 000000000..71f9523ac --- /dev/null +++ b/joi/src/core/Checkbox/index.tsx @@ -0,0 +1,51 @@ +import React, { ChangeEvent, InputHTMLAttributes, ReactNode } from 'react' + +import { twMerge } from 'tailwind-merge' + +import './styles.scss' + +export interface CheckboxProps extends InputHTMLAttributes { + disabled?: boolean + className?: string + label?: ReactNode + helperDescription?: ReactNode + errorMessage?: string + onChange?: (e: ChangeEvent) => void +} + +const Checkbox = ({ + id, + name, + checked, + disabled, + label, + defaultChecked, + helperDescription, + errorMessage, + className, + onChange, + ...props +}: CheckboxProps) => { + return ( +
+ +
+ +

{helperDescription}

+ {errorMessage &&

{errorMessage}

} +
+
+ ) +} +export { Checkbox } diff --git a/joi/src/core/Checkbox/styles.scss b/joi/src/core/Checkbox/styles.scss new file mode 100644 index 000000000..775a6289b --- /dev/null +++ b/joi/src/core/Checkbox/styles.scss @@ -0,0 +1,51 @@ +.checkbox { + @apply inline-flex items-start space-x-2; + + > input[type='checkbox'] { + @apply flex h-4 w-4 flex-shrink-0 cursor-pointer appearance-none items-center justify-center; + background-color: transparent; + margin-top: 1px; + border: 1px solid hsla(var(--app-border)); + border-radius: 4px; + &:focus, + &:focus-within { + @apply outline-2 outline-offset-4; + } + + &:checked { + background-color: hsla(var(--primary-bg)); + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 16 16' fill='%23fff' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12.207 4.793a1 1 0 0 1 0 1.414l-5 5a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L6.5 9.086l4.293-4.293a1 1 0 0 1 1.414 0z'/%3E%3C/svg%3E"); + } + + &:disabled { + background-color: hsla(var(----disabled-bg)); + color: hsla(var(--disabled-fg)); + + &:checked { + background-color: hsla(var(--primary-bg)); + @apply cursor-not-allowed opacity-50; + } + + & + div > .checkbox__label { + @apply cursor-not-allowed opacity-50; + } + } + } + + &__helper { + font-size: 12px; + } + + &__error { + color: hsla(var(--destructive-bg)); + } + + &__label { + @apply inline-block cursor-pointer; + } + + &:disabled { + background-color: hsla(var(----disabled-bg)); + color: hsla(var(--disabled-fg)); + } +} diff --git a/joi/src/core/Input/index.tsx b/joi/src/core/Input/index.tsx new file mode 100644 index 000000000..d82099e9c --- /dev/null +++ b/joi/src/core/Input/index.tsx @@ -0,0 +1,46 @@ +import React, { ReactNode, forwardRef } from 'react' +import { twMerge } from 'tailwind-merge' + +import './styles.scss' + +export interface Props extends React.InputHTMLAttributes { + textAlign?: 'left' | 'right' + prefixIcon?: ReactNode + suffixIcon?: ReactNode + onCLick?: () => void +} + +const Input = forwardRef( + ( + { className, type, textAlign, prefixIcon, suffixIcon, onClick, ...props }, + ref + ) => { + return ( +
+ {prefixIcon && ( +
+ {prefixIcon} +
+ )} + {suffixIcon && ( +
+ {suffixIcon} +
+ )} + +
+ ) + } +) + +export { Input } diff --git a/joi/src/core/Input/styles.scss b/joi/src/core/Input/styles.scss new file mode 100644 index 000000000..a6226fa06 --- /dev/null +++ b/joi/src/core/Input/styles.scss @@ -0,0 +1,43 @@ +.input { + background-color: hsla(var(--input-bg)); + border: 1px solid hsla(var(--app-border)); + @apply inline-flex h-8 w-full items-center rounded-md border px-3 transition-colors; + @apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-1 focus-visible:ring-[hsla(var(--primary-bg))] focus-visible:ring-offset-0; + @apply file:border-0 file:bg-transparent file:font-medium; + @apply hover:border-[hsla(var(--primary-bg))]; + + &__wrapper { + position: relative; + } + + &.text-right { + text-align: right; + } + + &::placeholder { + color: hsla(var(--input-placeholder)); + } + + &:disabled { + color: hsla(var(--disabled-fg)); + background-color: hsla(var(--disabled-bg)); + cursor: not-allowed; + border: none; + } + + &__prefix-icon { + @apply absolute left-3 top-1/2 -translate-y-1/2 cursor-pointer; + color: hsla(var(--input-icon)); + + .input { + padding-left: 32px; + } + } + + &__suffix-icon { + @apply absolute right-3 top-1/2 -translate-y-1/2 cursor-pointer; + color: hsla(var(--input-icon)); + + .input { + padding-right: 32px; + } + } +} diff --git a/joi/src/core/Modal/index.tsx b/joi/src/core/Modal/index.tsx new file mode 100644 index 000000000..923004b99 --- /dev/null +++ b/joi/src/core/Modal/index.tsx @@ -0,0 +1,56 @@ +import React, { ReactNode } from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { Cross2Icon } from '@radix-ui/react-icons' + +import './styles.scss' +import { twMerge } from 'tailwind-merge' + +type Props = { + trigger?: ReactNode + content: ReactNode + open?: boolean + className?: string + fullPage?: boolean + hideClose?: boolean + title?: ReactNode + onOpenChange?: (open: boolean) => void +} + +const ModalClose = DialogPrimitive.Close + +const Modal = ({ + trigger, + content, + open, + title, + fullPage, + className, + onOpenChange, + hideClose, +}: Props) => ( + + {trigger} + + + +
{title}
+ {content} + {!hideClose && ( + + + + )} +
+
+
+) + +export { Modal, ModalClose } diff --git a/joi/src/core/Modal/styles.scss b/joi/src/core/Modal/styles.scss new file mode 100644 index 000000000..755daaf3d --- /dev/null +++ b/joi/src/core/Modal/styles.scss @@ -0,0 +1,86 @@ +/* reset */ +button, +fieldset, +.modal { + &__overlay { + @apply backdrop-blur-lg; + background-color: hsla(var(--modal-overlay)); + z-index: 200; + position: fixed; + inset: 0; + animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + } + + &__content { + color: hsla(var(--modal-fg)); + overflow: hidden; + background-color: hsla(var(--modal-bg)); + border-radius: 8px; + font-size: 14px; + position: fixed; + z-index: 300; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 50vw; + max-width: 560px; + max-height: 85vh; + padding: 16px; + animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1); + border: 1px solid hsla(var(--app-border)); + @apply w-full; + + &--fullpage { + max-width: none; + width: 90vw; + max-height: 90vh; + } + + &:focus { + outline: none; + } + } + + &__title { + @apply line-clamp-1; + margin: 0 0 8px 0; + padding-right: 16px; + font-weight: 600; + color: hsla(var(--modal-fg)); + font-size: 18px; + } + + &__close-icon { + font-family: inherit; + border-radius: 100%; + height: 24px; + width: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + color: hsla(var(--modal-fg)); + position: absolute; + top: 8px; + right: 16px; + } +} + +@keyframes overlayShow { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes contentShow { + from { + opacity: 0; + transform: translate(-50%, -48%) scale(0.96); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } +} diff --git a/joi/src/core/Progress/index.tsx b/joi/src/core/Progress/index.tsx new file mode 100644 index 000000000..51ea79c81 --- /dev/null +++ b/joi/src/core/Progress/index.tsx @@ -0,0 +1,39 @@ +import React, { HTMLAttributes } from 'react' + +import { cva, type VariantProps } from 'class-variance-authority' + +import { twMerge } from 'tailwind-merge' + +import './styles.scss' + +const progressVariants = cva('progress', { + variants: { + size: { + small: 'progress--small', + medium: 'progress--medium', + large: 'progress--large', + }, + }, + defaultVariants: { + size: 'medium', + }, +}) + +export interface ProgressProps + extends HTMLAttributes, + VariantProps { + value: number +} + +const Progress = ({ className, size, value, ...props }: ProgressProps) => { + return ( +
+
+
+ ) +} + +export { Progress } diff --git a/joi/src/core/Progress/styles.scss b/joi/src/core/Progress/styles.scss new file mode 100644 index 000000000..02d22f5f4 --- /dev/null +++ b/joi/src/core/Progress/styles.scss @@ -0,0 +1,25 @@ +.progress { + background-color: hsla(var(--progress-track-bg)); + border-radius: 8px; + position: relative; + overflow: hidden; + @apply transition-all; + + &--indicator { + background-color: hsla(var(--primary-bg)); + position: absolute; + border-radius: 8px; + width: 100%; + height: 100%; + } + + &--small { + height: 6px; + } + &--medium { + @apply h-2; + } + &--large { + @apply h-3; + } +} diff --git a/joi/src/core/ScrollArea/index.tsx b/joi/src/core/ScrollArea/index.tsx new file mode 100644 index 000000000..3a2ffaaa8 --- /dev/null +++ b/joi/src/core/ScrollArea/index.tsx @@ -0,0 +1,39 @@ +import React, { PropsWithChildren, forwardRef } from 'react' +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' +import { twMerge } from 'tailwind-merge' + +import './styles.scss' + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, onScroll, ...props }, ref) => ( + + + {children} + + + + + + + + + +)) + +export { ScrollArea } diff --git a/joi/src/core/ScrollArea/styles.scss b/joi/src/core/ScrollArea/styles.scss new file mode 100644 index 000000000..cb5832c53 --- /dev/null +++ b/joi/src/core/ScrollArea/styles.scss @@ -0,0 +1,69 @@ +.scroll-area { + position: relative; + z-index: 999; + + &__root { + width: 200px; + height: 225px; + overflow: hidden; + } + + &__viewport { + width: 100%; + height: 100%; + border-radius: inherit; + } + + &__bar { + display: flex; + user-select: none; + touch-action: none; + padding: 1px; + background: hsla(var(--scrollbar-tracker)); + transition: background 160ms ease-out; + } + + &__thumb { + flex: 1; + background: hsla(var(--scrollbar-thumb)); + border-radius: 20px; + position: relative; + + ::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100%; + height: 100%; + min-width: 44px; + min-height: 44px; + } + } +} + +.scroll-area__bar[data-orientation='vertical'] { + width: 8px; +} + +.scroll-area__bar[data-orientation='horizontal'] { + flex-direction: column; + height: 8px; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track, +::-webkit-scrollbar-thumb { + background-clip: content-box; + border-radius: inherit; +} +::-webkit-scrollbar-track { + background: hsla(var(--scrollbar-tracker)); +} +::-webkit-scrollbar-thumb { + background: hsla(var(--scrollbar-thumb)); +} diff --git a/joi/src/core/Select/index.tsx b/joi/src/core/Select/index.tsx new file mode 100644 index 000000000..bce5473da --- /dev/null +++ b/joi/src/core/Select/index.tsx @@ -0,0 +1,85 @@ +import React, { ReactNode } from 'react' + +import * as SelectPrimitive from '@radix-ui/react-select' +import { + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, +} from '@radix-ui/react-icons' + +import './styles.scss' +import { twMerge } from 'tailwind-merge' + +type Props = { + options?: { name: string; value: string }[] + open?: boolean + block?: boolean + value?: string + placeholder?: string + disabled?: boolean + containerPortal?: HTMLDivElement | undefined | null + className?: string + onValueChange?: (value: string) => void + onOpenChange?: (open: boolean) => void +} + +const Select = ({ + placeholder, + options, + value, + disabled, + containerPortal, + block, + className, + open, + onValueChange, + onOpenChange, +}: Props) => ( + + + + + + + + + + + + {options && + options.map((item, i) => { + return ( + + + {item.name} + + + + + + ) + })} + + + + + +) + +export { Select } diff --git a/joi/src/core/Select/styles.scss b/joi/src/core/Select/styles.scss new file mode 100644 index 000000000..573833890 --- /dev/null +++ b/joi/src/core/Select/styles.scss @@ -0,0 +1,77 @@ +.select { + padding: 0 16px; + background-color: hsla(var(--select-input-bg)) !important; + border: 1px solid hsla(var(--app-border)); + @apply inline-flex h-8 items-center justify-between gap-8 rounded-md px-3 transition-colors; + @apply focus-within:outline-none focus-visible:outline-0 focus-visible:ring-1 focus-visible:ring-[hsla(var(--primary-bg))] focus-visible:ring-offset-0; + @apply text-sm hover:border-[hsla(var(--primary-bg))]; + + &[data-placeholder] { + color: hsla(var(--select-placeholder)); + } + + &__icon { + color: hsla(var(--select-icon)); + } + + &__content { + overflow: hidden; + background-color: hsla(var(--select-bg)); + z-index: 999; + border: 1px solid hsla(var(--select-border)); + border-radius: 8px; + box-shadow: + 0px 10px 38px -10px rgba(22, 23, 24, 0.35), + 0px 10px 20px -15px rgba(22, 23, 24, 0.2); + } + + &__viewport { + } + + &__disabled { + cursor: not-allowed; + pointer-events: none; + background-color: hsla(var(--disabled-bg)) !important; + color: hsla(var(--disabled-fg)); + border: none; + } + + &__item { + display: flex; + align-items: center; + padding: 8px 32px 8px 16px; + position: relative; + cursor: pointer; + @apply text-sm; + + &:hover { + background-color: hsla(var(--select-options-active-bg)); + } + + &[data-disabled] { + pointer-events: none; + } + + &[data-highlighted] { + outline: none; + } + } + + &__item-indicator { + position: absolute; + right: 0; + width: 25px; + display: inline-flex; + align-items: center; + justify-content: center; + } + + &__scroll-button { + display: flex; + align-items: center; + justify-content: center; + height: 25px; + background-color: white; + cursor: default; + } +} diff --git a/joi/src/core/Slider/index.tsx b/joi/src/core/Slider/index.tsx new file mode 100644 index 000000000..40e0c3977 --- /dev/null +++ b/joi/src/core/Slider/index.tsx @@ -0,0 +1,45 @@ +import React from 'react' +import * as SliderPrimitive from '@radix-ui/react-slider' + +import './styles.scss' + +type Props = { + name?: string + min?: number + max?: number + onValueChange?(value: number[]): void + value?: number[] + defaultValue?: number[] + step?: number + disabled?: boolean +} + +const Slider = ({ + name, + min, + max, + onValueChange, + value, + defaultValue, + step, + disabled, +}: Props) => ( + + + + + + +) + +export { Slider } diff --git a/joi/src/core/Slider/styles.scss b/joi/src/core/Slider/styles.scss new file mode 100644 index 000000000..019e5ba38 --- /dev/null +++ b/joi/src/core/Slider/styles.scss @@ -0,0 +1,38 @@ +.slider { + position: relative; + display: flex; + align-items: center; + user-select: none; + touch-action: none; + height: 16px; + + &__track { + background-color: hsla(var(--slider-track-bg)); + position: relative; + flex-grow: 1; + border-radius: 9999px; + height: 4px; + } + + &__range { + position: absolute; + background-color: hsla(var(--primary-bg)); + border-radius: 9999px; + height: 100%; + } + + &__thumb { + display: block; + width: 16px; + height: 16px; + background-color: hsla(var(--slider-thumb-bg)); + border-radius: 10px; + padding: 2px; + border: 2px solid hsla(var(--primary-bg)); + + &:focus { + outline: none; + box-shadow: 0 0 0 5px hsla(var(--slider-track-bg), 50%); + } + } +} diff --git a/joi/src/core/Switch/index.tsx b/joi/src/core/Switch/index.tsx new file mode 100644 index 000000000..28eabe6e6 --- /dev/null +++ b/joi/src/core/Switch/index.tsx @@ -0,0 +1,37 @@ +import React, { ChangeEvent, InputHTMLAttributes } from 'react' + +import { twMerge } from 'tailwind-merge' + +import './styles.scss' + +export interface SwitchProps extends InputHTMLAttributes { + disabled?: boolean + className?: string + onChange?: (e: ChangeEvent) => void +} + +const Switch = ({ + name, + checked, + disabled, + defaultChecked, + className, + onChange, + ...props +}: SwitchProps) => { + return ( + + ) +} +export { Switch } diff --git a/joi/src/core/Switch/styles.scss b/joi/src/core/Switch/styles.scss new file mode 100644 index 000000000..9f7adbd4f --- /dev/null +++ b/joi/src/core/Switch/styles.scss @@ -0,0 +1,67 @@ +.switch { + position: relative; + display: inline-block; + width: 32px; + height: 18px; + + > input { + opacity: 0; + width: 0; + height: 0; + + // disabled + &:disabled { + + .switch--thumb { + cursor: not-allowed; + background-color: hsla(var(--disabled-bg)); + &:before { + background-color: hsla(var(--disabled-fg)); + } + } + // disabled and checked + &:checked + .switch--thumb { + cursor: not-allowed; + background-color: hsla(var(--primary-bg)); + &:before { + background-color: hsla(var(--disabled-fg)); + } + } + } + + &:checked + .switch--thumb { + background-color: hsla(var(--primary-bg)); + + &::before { + -webkit-transform: translateX(14px); + -ms-transform: translateX(14px); + transform: translateX(14px); + } + } + } + + &--thumb { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: hsla(var(--switch-bg)); + -webkit-transition: 0.4s; + transition: 0.4s; + border-radius: 20px; + + &:before { + position: absolute; + content: ''; + height: 14px; + width: 14px; + left: 2px; + bottom: 2px; + background-color: hsla(var(--switch-fg)); + -webkit-transition: 0.4s; + transition: 0.4s; + border-radius: 50%; + } + } +} diff --git a/joi/src/core/Tabs/index.tsx b/joi/src/core/Tabs/index.tsx new file mode 100644 index 000000000..edec179f1 --- /dev/null +++ b/joi/src/core/Tabs/index.tsx @@ -0,0 +1,59 @@ +import React, { ReactNode } from 'react' + +import * as TabsPrimitive from '@radix-ui/react-tabs' + +import './styles.scss' + +type TabsProps = { + options: { name: string; value: string }[] + children: ReactNode + defaultValue?: string + value: string + onValueChange?: (value: string) => void +} + +type TabsContentProps = { + value: string + children: ReactNode +} + +const TabsContent = ({ value, children }: TabsContentProps) => { + return ( + + {children} + + ) +} + +const Tabs = ({ + options, + children, + defaultValue, + value, + onValueChange, +}: TabsProps) => ( + + + {options.map((option, i) => { + return ( + + {option.name} + + ) + })} + + + {children} + +) + +export { Tabs, TabsContent } diff --git a/joi/src/core/Tabs/styles.scss b/joi/src/core/Tabs/styles.scss new file mode 100644 index 000000000..86948ab5a --- /dev/null +++ b/joi/src/core/Tabs/styles.scss @@ -0,0 +1,37 @@ +.tabs { + display: flex; + flex-direction: column; + width: 100%; + + &__list { + flex-shrink: 0; + display: flex; + border-bottom: 1px solid hsla(var(--app-border)); + } + + &__trigger { + padding: 0 12px; + flex: 1; + height: 38px; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + user-select: none; + &:focus { + position: relative; + } + } + + &__content { + flex-grow: 1; + border-bottom-left-radius: 6px; + border-bottom-right-radius: 6px; + outline: none; + } +} + +.tabs__trigger[data-state='active'] { + border-bottom: 1px solid hsla(var(--primary-bg)); + font-weight: 600; +} diff --git a/joi/src/core/TextArea/index.tsx b/joi/src/core/TextArea/index.tsx new file mode 100644 index 000000000..33d6744ad --- /dev/null +++ b/joi/src/core/TextArea/index.tsx @@ -0,0 +1,24 @@ +import React, { ReactNode, forwardRef } from 'react' +import { twMerge } from 'tailwind-merge' + +import './styles.scss' +import { ScrollArea } from '../ScrollArea' + +export interface TextAreaProps + extends React.TextareaHTMLAttributes {} + +const TextArea = forwardRef( + ({ className, ...props }, ref) => { + return ( +
+