From 719d900ac43172b6845b674be02b2db2cb02e958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tolga=20Yayc=C4=B1?= <40897846+tolgayayci@users.noreply.github.com> Date: Tue, 6 Feb 2024 16:38:52 +0300 Subject: [PATCH 1/5] pages created and some modules implemented --- .gitignore | 5 + .vscode/launch.json | 40 + .vscode/tasks.json | 21 + components.json | 17 + electron-builder.yml | 12 + main/background.ts | 258 + main/helpers/create-window.ts | 86 + main/helpers/index.ts | 1 + main/helpers/manage-identities.ts | 60 + main/helpers/manage-projects.ts | 57 + main/helpers/soroban-helper.ts | 47 + main/preload.ts | 70 + package.json | 61 + .../common/is-soroban-installed.tsx | 56 + renderer/components/icons.tsx | 71 + .../identities/forms/createNewIdentity.ts | 55 + .../identities/forms/importNewIdentity.ts | 45 + .../identities/forms/removeIdentity.ts | 33 + .../identities/forms/renameIdentity.ts | 38 + .../components/identities/identity-modal.tsx | 606 ++ .../identities/identity-switcher.tsx | 251 + .../components/identities/no-identities.tsx | 13 + renderer/components/layout.tsx | 149 + renderer/components/projects/Projects.tsx | 309 + .../projects/forms/addExistingProject.ts | 36 + .../projects/forms/createNewProject.ts | 47 + .../projects/forms/removeProject.ts | 23 + .../projects/forms/renameProject.ts | 45 + renderer/components/projects/no-project.tsx | 10 + .../components/projects/project-modal.tsx | 373 ++ renderer/components/settings/Settings.tsx | 25 + renderer/components/sidebar-nav.tsx | 86 + renderer/components/theme-provider.tsx | 9 + renderer/components/toggle-mode.tsx | 43 + renderer/components/toggle-reload.tsx | 24 + renderer/components/ui/accordion.tsx | 56 + renderer/components/ui/alert-dialog.tsx | 139 + renderer/components/ui/alert.tsx | 59 + renderer/components/ui/avatar.tsx | 48 + renderer/components/ui/button.tsx | 55 + renderer/components/ui/card.tsx | 79 + renderer/components/ui/checkbox.tsx | 28 + renderer/components/ui/command.tsx | 153 + renderer/components/ui/dialog.tsx | 120 + renderer/components/ui/dropdown-menu.tsx | 198 + renderer/components/ui/form.tsx | 177 + renderer/components/ui/input.tsx | 25 + renderer/components/ui/label.tsx | 24 + renderer/components/ui/popover.tsx | 29 + renderer/components/ui/resizable.tsx | 43 + renderer/components/ui/scroll-area.tsx | 46 + renderer/components/ui/select.tsx | 158 + renderer/components/ui/separator.tsx | 29 + renderer/components/ui/switch.tsx | 27 + renderer/components/ui/tabs.tsx | 53 + renderer/components/ui/toast.tsx | 127 + renderer/components/ui/toaster.tsx | 33 + renderer/components/ui/tooltip.tsx | 28 + renderer/components/ui/use-toast.ts | 189 + renderer/lib/notifications.ts | 103 + renderer/lib/utils.ts | 6 + renderer/next-env.d.ts | 5 + renderer/next.config.js | 10 + renderer/pages/_app.tsx | 42 + renderer/pages/contracts/[id]/index.tsx | 0 renderer/pages/identities.tsx | 410 ++ renderer/pages/projects/[path]/index.tsx | 0 renderer/pages/projects/index.tsx | 16 + renderer/pages/settings.tsx | 16 + renderer/postcss.config.js | 8 + renderer/preload.d.ts | 7 + renderer/public/images/logo.png | Bin 0 -> 120101 bytes renderer/styles/globals.css | 76 + renderer/tailwind.config.js | 74 + renderer/tsconfig.json | 12 + renderer/types/electron.d.ts | 26 + resources/icon.icns | Bin 0 -> 213238 bytes resources/icon.ico | Bin 0 -> 120101 bytes tsconfig.json | 19 + yarn.lock | 5224 +++++++++++++++++ 80 files changed, 11059 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json create mode 100644 components.json create mode 100644 electron-builder.yml create mode 100644 main/background.ts create mode 100644 main/helpers/create-window.ts create mode 100644 main/helpers/index.ts create mode 100644 main/helpers/manage-identities.ts create mode 100644 main/helpers/manage-projects.ts create mode 100644 main/helpers/soroban-helper.ts create mode 100644 main/preload.ts create mode 100644 package.json create mode 100644 renderer/components/common/is-soroban-installed.tsx create mode 100644 renderer/components/icons.tsx create mode 100644 renderer/components/identities/forms/createNewIdentity.ts create mode 100644 renderer/components/identities/forms/importNewIdentity.ts create mode 100644 renderer/components/identities/forms/removeIdentity.ts create mode 100644 renderer/components/identities/forms/renameIdentity.ts create mode 100644 renderer/components/identities/identity-modal.tsx create mode 100644 renderer/components/identities/identity-switcher.tsx create mode 100644 renderer/components/identities/no-identities.tsx create mode 100644 renderer/components/layout.tsx create mode 100644 renderer/components/projects/Projects.tsx create mode 100644 renderer/components/projects/forms/addExistingProject.ts create mode 100644 renderer/components/projects/forms/createNewProject.ts create mode 100644 renderer/components/projects/forms/removeProject.ts create mode 100644 renderer/components/projects/forms/renameProject.ts create mode 100644 renderer/components/projects/no-project.tsx create mode 100644 renderer/components/projects/project-modal.tsx create mode 100644 renderer/components/settings/Settings.tsx create mode 100644 renderer/components/sidebar-nav.tsx create mode 100644 renderer/components/theme-provider.tsx create mode 100644 renderer/components/toggle-mode.tsx create mode 100644 renderer/components/toggle-reload.tsx create mode 100644 renderer/components/ui/accordion.tsx create mode 100644 renderer/components/ui/alert-dialog.tsx create mode 100644 renderer/components/ui/alert.tsx create mode 100644 renderer/components/ui/avatar.tsx create mode 100644 renderer/components/ui/button.tsx create mode 100644 renderer/components/ui/card.tsx create mode 100644 renderer/components/ui/checkbox.tsx create mode 100644 renderer/components/ui/command.tsx create mode 100644 renderer/components/ui/dialog.tsx create mode 100644 renderer/components/ui/dropdown-menu.tsx create mode 100644 renderer/components/ui/form.tsx create mode 100644 renderer/components/ui/input.tsx create mode 100644 renderer/components/ui/label.tsx create mode 100644 renderer/components/ui/popover.tsx create mode 100644 renderer/components/ui/resizable.tsx create mode 100644 renderer/components/ui/scroll-area.tsx create mode 100644 renderer/components/ui/select.tsx create mode 100644 renderer/components/ui/separator.tsx create mode 100644 renderer/components/ui/switch.tsx create mode 100644 renderer/components/ui/tabs.tsx create mode 100644 renderer/components/ui/toast.tsx create mode 100644 renderer/components/ui/toaster.tsx create mode 100644 renderer/components/ui/tooltip.tsx create mode 100644 renderer/components/ui/use-toast.ts create mode 100644 renderer/lib/notifications.ts create mode 100644 renderer/lib/utils.ts create mode 100644 renderer/next-env.d.ts create mode 100644 renderer/next.config.js create mode 100644 renderer/pages/_app.tsx create mode 100644 renderer/pages/contracts/[id]/index.tsx create mode 100644 renderer/pages/identities.tsx create mode 100644 renderer/pages/projects/[path]/index.tsx create mode 100644 renderer/pages/projects/index.tsx create mode 100644 renderer/pages/settings.tsx create mode 100644 renderer/postcss.config.js create mode 100644 renderer/preload.d.ts create mode 100644 renderer/public/images/logo.png create mode 100644 renderer/styles/globals.css create mode 100644 renderer/tailwind.config.js create mode 100644 renderer/tsconfig.json create mode 100644 renderer/types/electron.d.ts create mode 100644 resources/icon.icns create mode 100644 resources/icon.ico create mode 100644 tsconfig.json create mode 100644 yarn.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0fc7a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +*.log +.next +app +dist diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..43b8484 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,40 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Nextron: Main", + "type": "node", + "request": "attach", + "protocol": "inspector", + "port": 9292, + "skipFiles": ["/**"], + "sourceMapPathOverrides": { + "webpack:///./~/*": "${workspaceFolder}/node_modules/*", + "webpack:///./*": "${workspaceFolder}/*", + "webpack:///*": "*" + } + }, + { + "name": "Nextron: Renderer", + "type": "chrome", + "request": "attach", + "port": 5858, + "timeout": 10000, + "urlFilter": "http://localhost:*", + "webRoot": "${workspaceFolder}/app", + "sourceMapPathOverrides": { + "webpack:///./src/*": "${webRoot}/*" + } + } + ], + "compounds": [ + { + "name": "Nextron: All", + "preLaunchTask": "dev", + "configurations": ["Nextron: Main", "Nextron: Renderer"] + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..5729039 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,21 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "dev", + "isBackground": true, + "problemMatcher": { + "owner": "custom", + "pattern": { + "regexp": "" + }, + "background": { + "beginsPattern": "started server", + "endsPattern": "Debugger listening on" + } + }, + "label": "dev" + } + ] +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..71e2b90 --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "renderer/tailwind.config.js", + "css": "renderer/styles/globals.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "renderer/components", + "utils": "lib/utils" + } +} diff --git a/electron-builder.yml b/electron-builder.yml new file mode 100644 index 0000000..1b5a3bb --- /dev/null +++ b/electron-builder.yml @@ -0,0 +1,12 @@ +appId: com.example.nextron +productName: My Nextron App +copyright: Copyright © 2018 Yoshihide Shiono +directories: + output: dist + buildResources: resources +files: + - from: . + filter: + - package.json + - app +publish: null diff --git a/main/background.ts b/main/background.ts new file mode 100644 index 0000000..c71f6b0 --- /dev/null +++ b/main/background.ts @@ -0,0 +1,258 @@ +// const fixPath = require("fix-path"); +// fixPath(); + +import { app, ipcMain, dialog } from "electron"; +import serve from "electron-serve"; + +import { createWindow } from "./helpers"; +import { executeSorobanCommand } from "./helpers/soroban-helper"; +import { handleProjects } from "./helpers/manage-projects"; +import { handleIdentities } from "./helpers/manage-identities"; + +const path = require("node:path"); +const fs = require("fs"); +const toml = require("toml"); +const { shell } = require("electron"); + +const isProd = process.env.NODE_ENV === "production"; + +const Store = require("electron-store"); + +const schema = { + projects: { + type: "array", + default: [], + items: { + type: "object", + properties: { + name: { type: "string" }, + path: { type: "string" }, + active: { type: "boolean" }, + }, + required: ["name", "path"], + }, + }, + identities: { + type: "array", + default: [], + items: { + type: "object", + properties: { + name: { type: "string" }, + address: { type: "string" }, + }, + }, + }, +}; + +const store = new Store({ schema }); + +async function handleFileOpen() { + const { canceled, filePaths } = await dialog.showOpenDialog({ + properties: ["openDirectory"], + }); + if (!canceled) { + return filePaths[0]; + } +} + +if (isProd) { + serve({ directory: "app" }); +} else { + app.setPath("userData", `${app.getPath("userData")} (development)`); +} + +(async () => { + await app.whenReady(); + + const mainWindow = createWindow("main", { + width: 1500, + height: 700, + webPreferences: { + preload: path.join(__dirname, "preload.js"), + }, + }); + + ipcMain.handle("app:reload", () => { + if (mainWindow) { + mainWindow.reload(); + } + }); + + ipcMain.handle("open-external-link", async (event, url) => { + if (url) { + await shell.openExternal(url); + } + }); + + ipcMain.handle( + "soroban-command", + async (event, command, subcommand, args?, flags?, path?) => { + try { + const result = await executeSorobanCommand( + command, + subcommand, + args, + flags, + path + ); + return result; + } catch (error) { + console.error(`Error while executing Soroban command: ${error}`); + throw error; + } + } + ); + + ipcMain.handle("dialog:openDirectory", handleFileOpen); + + // Store: Projects Handler + ipcMain.handle("store:manageProjects", async (event, action, project) => { + try { + const result = await handleProjects(store, action, project); + return result; + } catch (error) { + console.error("Error on projects:", error); + throw error; + } + }); + + ipcMain.handle( + "store:manageIdentities", + async (event, action, identity, newIdentity?) => { + try { + const result = await handleIdentities( + store, + action, + identity, + newIdentity + ); + return result; + } catch (error) { + console.error("Error on identities:", error); + throw error; + } + } + ); + + ipcMain.handle("is-soroban-project", async (event, directoryPath) => { + try { + const cargoTomlPath = path.join(directoryPath, "Cargo.toml"); + if (!fs.existsSync(cargoTomlPath)) { + return false; + } + + const cargoTomlContent = fs.readFileSync(cargoTomlPath, "utf8"); + const parsedToml = toml.parse(cargoTomlContent); + + if (parsedToml.dependencies && "soroban-sdk" in parsedToml.dependencies) { + return true; + } + + return false; + } catch (error) { + console.error(`Error while checking for Soroban project: ${error}`); + return false; + } + }); + + ipcMain.handle("is-soroban-installed", async (event) => { + try { + if (mainWindow) { + const result = await executeSorobanCommand("--version"); + const isDfxInstalled = result.trim().startsWith("soroban"); + return isDfxInstalled; + } else { + console.error("Main window not found"); + } + } catch (error) { + console.error(`Error while checking for Dfinity installation: ${error}`); + return false; + } + }); + + // IPC handler for reading the JSON file + ipcMain.handle("json:read", async (event, filePath, directoryPath) => { + try { + const data = fs.readFileSync(path.join(filePath, directoryPath), "utf8"); + return JSON.parse(data); + } catch (error) { + console.error("Failed to read file", error); + return null; // or handle error as needed + } + }); + + // IPC handler for updating the JSON file + ipcMain.handle( + "json:update", + async (event, filePath, directoryPath, jsonContent) => { + try { + fs.writeFileSync( + path.join(filePath, directoryPath), + JSON.stringify(jsonContent, null, 2), + "utf8" + ); + return true; // success + } catch (error) { + console.error("Failed to write file", error); + return false; // or handle error as needed + } + } + ); + + async function retrieveAndStoreIdentities() { + try { + const result = await executeSorobanCommand("config", "identity", ["ls"]); + // Split the result string into an array of identities + const identityNames = result + .split("\n") + .filter( + (identity) => identity.trim() !== "" && identity.trim() !== "*" + ); + + for (const name of identityNames) { + // Create an identity object + const identity = { + name: name, + }; + + // Add each identity to the store + try { + await handleIdentities(store, "add", identity); + } catch (error) { + console.error(`Error adding identity '${name}':`, error); + } + } + } catch (error) { + console.error("Error retrieving identities:", error); + } + } + + ipcMain.handle("identity:refresh", async (event) => { + try { + const envVars = retrieveAndStoreIdentities(); + return envVars; + } catch (error) { + console.error("Failed to read identities from dfx:", error); + return { error }; + } + }); + + await retrieveAndStoreIdentities(); + + if (isProd) { + await mainWindow.loadURL("app://./projects"); + } else { + const port = process.argv[2]; + await mainWindow.loadURL(`http://localhost:${port}/projects`); + mainWindow.webContents.openDevTools(); + } +})(); + +app.on("window-all-closed", () => { + app.quit(); +}); + +ipcMain.on("message", async (event, arg) => { + event.reply("message", `${arg} World!`); +}); diff --git a/main/helpers/create-window.ts b/main/helpers/create-window.ts new file mode 100644 index 0000000..b4deda5 --- /dev/null +++ b/main/helpers/create-window.ts @@ -0,0 +1,86 @@ +import { + screen, + BrowserWindow, + BrowserWindowConstructorOptions, + Rectangle, +} from 'electron' +import Store from 'electron-store' + +export const createWindow = ( + windowName: string, + options: BrowserWindowConstructorOptions +): BrowserWindow => { + const key = 'window-state' + const name = `window-state-${windowName}` + const store = new Store({ name }) + const defaultSize = { + width: options.width, + height: options.height, + } + let state = {} + + const restore = () => store.get(key, defaultSize) + + const getCurrentPosition = () => { + const position = win.getPosition() + const size = win.getSize() + return { + x: position[0], + y: position[1], + width: size[0], + height: size[1], + } + } + + const windowWithinBounds = (windowState, bounds) => { + return ( + windowState.x >= bounds.x && + windowState.y >= bounds.y && + windowState.x + windowState.width <= bounds.x + bounds.width && + windowState.y + windowState.height <= bounds.y + bounds.height + ) + } + + const resetToDefaults = () => { + const bounds = screen.getPrimaryDisplay().bounds + return Object.assign({}, defaultSize, { + x: (bounds.width - defaultSize.width) / 2, + y: (bounds.height - defaultSize.height) / 2, + }) + } + + const ensureVisibleOnSomeDisplay = (windowState) => { + const visible = screen.getAllDisplays().some((display) => { + return windowWithinBounds(windowState, display.bounds) + }) + if (!visible) { + // Window is partially or fully not visible now. + // Reset it to safe defaults. + return resetToDefaults() + } + return windowState + } + + const saveState = () => { + if (!win.isMinimized() && !win.isMaximized()) { + Object.assign(state, getCurrentPosition()) + } + store.set(key, state) + } + + state = ensureVisibleOnSomeDisplay(restore()) + + const win = new BrowserWindow({ + ...state, + ...options, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + ...options.webPreferences, + }, + }) + + win.on('close', saveState) + + return win +} diff --git a/main/helpers/index.ts b/main/helpers/index.ts new file mode 100644 index 0000000..e1b9aad --- /dev/null +++ b/main/helpers/index.ts @@ -0,0 +1 @@ +export * from './create-window' diff --git a/main/helpers/manage-identities.ts b/main/helpers/manage-identities.ts new file mode 100644 index 0000000..aa0ecec --- /dev/null +++ b/main/helpers/manage-identities.ts @@ -0,0 +1,60 @@ +export function handleIdentities(store, action, identity, newIdentity?) { + let identities = store.get("identities", []); + + switch (action) { + case "add": + if (!identity.name || identities.some((i) => i.name === identity.name)) { + throw new Error("Identity already exists or name is missing"); + } + + identities.push(identity); + break; + + case "rename": + const existingIdentityIndex = identities.findIndex( + (i) => i.name === identity.name + ); + if (existingIdentityIndex === -1) { + throw new Error("Identity to rename not found"); + } + if (identities.some((i) => i.name === newIdentity)) { + throw new Error("New identity name already exists"); + } + identities[existingIdentityIndex] = { + ...identities[existingIdentityIndex], + name: newIdentity, + }; + break; + + case "delete": + const keyToDelete = identity.isInternetIdentity + ? "internetIdentityPrincipal" + : "name"; + identities = identities.filter( + (i) => i[keyToDelete] !== identity[keyToDelete] + ); + break; + + case "get": + if (identity) { + const keyToFind = identity.isInternetIdentity + ? "internetIdentityPrincipal" + : "name"; + const requestedIdentity = identities.find( + (i) => i[keyToFind] === identity[keyToFind] + ); + return requestedIdentity || null; + } + return identities; + + case "list": + // Return all identities + return identities; + + default: + throw new Error("Invalid action"); + } + + store.set("identities", identities); + return identities; +} diff --git a/main/helpers/manage-projects.ts b/main/helpers/manage-projects.ts new file mode 100644 index 0000000..1720241 --- /dev/null +++ b/main/helpers/manage-projects.ts @@ -0,0 +1,57 @@ +export function handleProjects(store, action, project) { + let projects = store.get("projects", []); + + switch (action) { + case "add": + // Check if the project already exists + if (projects.some((p) => p.path === project.path)) { + throw new Error("Project already exists"); + } + projects.push(project); + break; + + case "update": + // Find the index of the project + const existingProjectIndex = projects.findIndex( + (p) => p.path === project.path + ); + if (existingProjectIndex === -1) { + throw new Error("Project not found"); + } + + // Set all projects to inactive + projects.forEach((p) => (p.active = false)); + + // Update the selected project and set it to active + projects[existingProjectIndex] = { ...project, active: true }; + break; + + case "delete": + const projectIndexToRemove = projects.findIndex( + (p) => p.path === project.path + ); + if (projectIndexToRemove === -1) { + throw new Error("Project not found"); + } + projects.splice(projectIndexToRemove, 1); + break; + + case "get": + if (project && project.path) { + // Return the requested project + const requestedProject = projects.find((p) => p.path === project.path); + return requestedProject || null; // Return null if not found + } + // Return all projects if no specific project is requested + return projects; + + default: + throw new Error("Invalid action"); + } + + // Update the store (not necessary for 'get' action) + store.set("projects", projects); + + // Return the updated projects array (not necessary for 'get' action) + return projects; +} diff --git a/main/helpers/soroban-helper.ts b/main/helpers/soroban-helper.ts new file mode 100644 index 0000000..28656af --- /dev/null +++ b/main/helpers/soroban-helper.ts @@ -0,0 +1,47 @@ +import { spawn } from "child_process"; + +export function executeSorobanCommand( + command: string, + subcommand?: string, + args?: string[], + flags?: string[], + path?: string +): Promise { + const argStr = args || []; + const flagStr = flags || []; + const allArgs = [command, subcommand, ...argStr, ...flagStr].filter(Boolean); + + const commandStr = `soroban ${allArgs.join(" ")}`; + + return new Promise((resolve, reject) => { + const child = spawn("soroban", allArgs, { cwd: path, shell: true }); + + let stdoutData = ""; + let stderrData = ""; + + child.stdout.on("data", (data) => { + stdoutData += data; + }); + + child.stderr.on("data", (data) => { + stderrData += data; + }); + + child.on("error", (error) => { + reject(error); + }); + + child.on("close", (code) => { + if (code !== 0) { + reject( + new Error( + `Command "${commandStr}" failed with exit code ${code}: ${stderrData}` + ) + ); + } else { + const combinedOutput = stdoutData + stderrData; + resolve(combinedOutput.trim()); + } + }); + }); +} diff --git a/main/preload.ts b/main/preload.ts new file mode 100644 index 0000000..dcac968 --- /dev/null +++ b/main/preload.ts @@ -0,0 +1,70 @@ +import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron"; + +const handler = { + send(channel: string, value: unknown) { + ipcRenderer.send(channel, value); + }, + on(channel: string, callback: (...args: unknown[]) => void) { + const subscription = (_event: IpcRendererEvent, ...args: unknown[]) => + callback(...args); + ipcRenderer.on(channel, subscription); + + return () => { + ipcRenderer.removeListener(channel, subscription); + }; + }, + + node: process.versions.node, + chrome: process.versions.chrome, + electron: process.versions.electron, + runSorobanCommand: async (command, subcommand, args, flags, path) => { + return ipcRenderer.invoke( + "soroban-command", + command, + subcommand, + args, + flags, + path + ); + }, + openDirectory: async () => { + return ipcRenderer.invoke("dialog:openDirectory"); + }, + manageProjects: async (action, project) => { + return ipcRenderer.invoke("store:manageProjects", action, project); + }, + manageIdentities: async (action, identity, newIdentity) => { + return ipcRenderer.invoke( + "store:manageIdentities", + action, + identity, + newIdentity + ); + }, + isSorobanProject: async (directoryPath) => { + return ipcRenderer.invoke("is-soroban-project", directoryPath); + }, + isSorobanInstalled: async () => { + return ipcRenderer.invoke("is-soroban-installed"); + }, + jsonRead: async (filePath, directoryPath) => { + return ipcRenderer.invoke("json:read", filePath, directoryPath); + }, + jsonWrite: async (filePath, directoryPath, data) => { + return ipcRenderer.invoke("json:update", filePath, directoryPath, data); + }, + reloadApplication: async () => { + console.log("Reloading application"); + return ipcRenderer.invoke("app:reload"); + }, + openExternalLink: async (url) => { + return ipcRenderer.invoke("open-external-link", url); + }, + refreshIdentities: async () => { + return ipcRenderer.invoke("identity:refresh"); + }, +}; + +contextBridge.exposeInMainWorld("sorobanApi", handler); + +export type IpcHandler = typeof handler; diff --git a/package.json b/package.json new file mode 100644 index 0000000..5fda3fd --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "private": true, + "name": "soroban-cli-gui", + "description": "Soroban Cli Cross Platform Desktop Application", + "version": "0.0.1", + "author": "Tolga Yaycı ", + "main": "app/background.js", + "scripts": { + "dev": "nextron", + "build": "nextron build", + "postinstall": "electron-builder install-app-deps" + }, + "dependencies": { + "@hookform/resolvers": "^3.3.4", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "cmdk": "^0.2.1", + "electron-serve": "^1.3.0", + "electron-store": "^8.1.0", + "fix-path": "^4.0.0", + "lucide-react": "^0.323.0", + "next-themes": "^0.2.1", + "react-hook-form": "^7.50.0", + "react-resizable-panels": "^2.0.3", + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7", + "toml": "^3.0.0", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/node": "^20.11.16", + "@types/react": "^18.2.52", + "autoprefixer": "^10.4.16", + "electron": "^28.2.1", + "electron-builder": "^24.9.1", + "next": "^12.3.4", + "nextron": "^8.13.0", + "postcss": "^8.4.30", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3" + } +} diff --git a/renderer/components/common/is-soroban-installed.tsx b/renderer/components/common/is-soroban-installed.tsx new file mode 100644 index 0000000..03de5ea --- /dev/null +++ b/renderer/components/common/is-soroban-installed.tsx @@ -0,0 +1,56 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "components/ui/alert-dialog"; + +export default function SorobanNotInstalled() { + async function openExternalLink(url: string) { + try { + await window.sorobanApi.openExternalLink(url); + } catch (error) { + console.error(`Error: ${error}`); + } + } + + async function reloadApplication() { + try { + await window.sorobanApi.reloadApplication(); + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + + + + Soroban is not installed! + + You need to install Soroban to use this application. Please visit + the repository for more information. + + + + reloadApplication() as any}> + Reload Application + + + openExternalLink( + "https://github.com/tolgayayci/soroban-cli-gui" + ) as any + } + > + Visit GitHub + + + + + ); +} diff --git a/renderer/components/icons.tsx b/renderer/components/icons.tsx new file mode 100644 index 0000000..4088bb1 --- /dev/null +++ b/renderer/components/icons.tsx @@ -0,0 +1,71 @@ +import { + AlertTriangle, + ArrowRight, + Check, + ChevronLeft, + ChevronRight, + Command, + CreditCard, + File, + FileText, + HelpCircle, + Image, + Laptop, + Loader2, + LucideProps, + Moon, + MoreVertical, + Pizza, + Plus, + Settings, + SunMedium, + Trash, + Twitter, + User, + X, + RefreshCwIcon, +} from "lucide-react"; + +export const Icons = { + logo: Command, + close: X, + spinner: Loader2, + chevronLeft: ChevronLeft, + chevronRight: ChevronRight, + trash: Trash, + post: FileText, + page: File, + media: Image, + settings: Settings, + billing: CreditCard, + ellipsis: MoreVertical, + add: Plus, + warning: AlertTriangle, + user: User, + arrowRight: ArrowRight, + help: HelpCircle, + pizza: Pizza, + sun: SunMedium, + moon: Moon, + laptop: Laptop, + reload: RefreshCwIcon, + gitHub: ({ ...props }: LucideProps) => ( + + ), + twitter: Twitter, + check: Check, +}; diff --git a/renderer/components/identities/forms/createNewIdentity.ts b/renderer/components/identities/forms/createNewIdentity.ts new file mode 100644 index 0000000..55ec5ad --- /dev/null +++ b/renderer/components/identities/forms/createNewIdentity.ts @@ -0,0 +1,55 @@ +import * as z from "zod"; + +export const newIdentityFormSchema = z.object({ + identity_name: z + .string() + .min(3, "Identity name must be at least 3 characters long.") + .max(255, "Identity name must be at most 255 characters long.") + .regex( + /^[A-Za-z0-9.\-_@]+$/, + "Only the characters ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_@0123456789 are valid in identity names." + ), + seed: z.string().optional(), + as_secret: z.boolean().optional(), + global: z.boolean().optional(), + hd_path: z.string().optional(), + default_seed: z.boolean().optional(), + // RPC Options + config_dir: z.string().optional(), + rpc_url: z.string().optional(), + network_passphrase: z.string().optional(), + network: z.string().optional(), +}); + +export async function onNewIdentityFormSubmit( + data: z.infer +) { + try { + const command = "config"; + const subcommand = "identity"; + const args = ["generate", data.identity_name]; + const flags = [ + data.seed ? `--seed=${data.seed}` : "", + data.as_secret ? "--as-secret" : "", + data.global ? "--global" : "", + data.hd_path ? `--hd-path=${data.hd_path}` : "", + data.default_seed ? "--default-seed" : "", + // Add RPC options if needed + data.rpc_url ? `--rpc-url=${data.rpc_url}` : "", + data.network_passphrase + ? `--network-passphrase=${data.network_passphrase}` + : "", + data.network ? `--network=${data.network}` : "", + ].filter(Boolean); + + await window.sorobanApi.runSorobanCommand(command, subcommand, args, flags); + + await window.sorobanApi.manageIdentities("add", { + name: data.identity_name, + }); + + await window.sorobanApi.reloadApplication(); + } catch (error) { + console.error(`Error: ${error}`); + } +} diff --git a/renderer/components/identities/forms/importNewIdentity.ts b/renderer/components/identities/forms/importNewIdentity.ts new file mode 100644 index 0000000..3137920 --- /dev/null +++ b/renderer/components/identities/forms/importNewIdentity.ts @@ -0,0 +1,45 @@ +import * as z from "zod"; + +export const importIdentityFormSchema = z.object({ + identity_name: z + .string() + .min(3, "Identity name must be at least 3 characters long.") + .max(255, "Identity name must be at most 255 characters long.") + .regex( + /^[A-Za-z0-9.\-_@]+$/, + "Only the characters ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_@0123456789 are valid in identity names." + ), + pem_identity: z + .string() + .min(3, { + message: "Identity name must be at least 3 characters long", + }) + .max(255), + storage_mode: z + .string() + .min(3, "Storage mode must be at least 3 characters long.") + .max(255, "Storage mode must be at most 255 characters long.") + .optional(), + force: z.boolean().optional(), +}); + +export async function onimportIdentityFormSubmit( + data: z.infer +) { + try { + const command = "identity"; + const subcommand = "import"; + const args = [data.identity_name]; + const flags = [ + data.pem_identity ? `pem-identity=${data.pem_identity}` : null, + data.storage_mode ? `storage-mode=${data.storage_mode}` : null, + data.force === true ? "force" : null, + ].filter(Boolean); + + await window.sorobanApi.runSorobanCommand(command, subcommand, args, flags); + + await window.sorobanApi.reloadApplication(); + } catch (error) { + console.error(`Error: ${error}`); + } +} diff --git a/renderer/components/identities/forms/removeIdentity.ts b/renderer/components/identities/forms/removeIdentity.ts new file mode 100644 index 0000000..616f2d2 --- /dev/null +++ b/renderer/components/identities/forms/removeIdentity.ts @@ -0,0 +1,33 @@ +import * as z from "zod"; + +export const removeIdentityFormSchema = z.object({ + identity_name: z + .string() + .min(3, "Identity name must be at least 3 characters long.") + .max(255, "Identity name must be at most 255 characters long.") + .regex( + /^[A-Za-z0-9.\-_@]+$/, + "Only the characters ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_@0123456789 are valid in identity names." + ), + drop_wallets: z.boolean().default(false), +}); + +export async function onRemoveIdentityFormSubmit( + data: z.infer +) { + try { + const command = "config"; + const subcommand = "identity"; + const args = ["rm", data.identity_name]; + + await window.sorobanApi.runSorobanCommand(command, subcommand, args); + + await window.sorobanApi.manageIdentities("delete", { + name: data.identity_name, + }); + + await window.sorobanApi.reloadApplication(); + } catch (error) { + console.error(`Error: ${error}`); + } +} diff --git a/renderer/components/identities/forms/renameIdentity.ts b/renderer/components/identities/forms/renameIdentity.ts new file mode 100644 index 0000000..88d72d3 --- /dev/null +++ b/renderer/components/identities/forms/renameIdentity.ts @@ -0,0 +1,38 @@ +import * as z from "zod"; + +export const renameIdentityFormSchema = z.object({ + from_identity_name: z + .string() + .min(3, "Identity name must be at least 3 characters long.") + .max(255, "Identity name must be at most 255 characters long."), + to_identity_name: z + .string() + .min(3, "Identity name must be at least 3 characters long.") + .max(255, "Identity name must be at most 255 characters long.") + .regex( + /^[A-Za-z0-9.\-_@]+$/, + "Only the characters ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_@0123456789 are valid in identity names." + ), +}); + +export async function onRenameIdentityFormSubmit( + data: z.infer +) { + try { + const command = "identity"; + const subcommand = "rename"; + const args = [data.from_identity_name, data.to_identity_name]; + + await window.sorobanApi.runSorobanCommand(command, subcommand, args); + + await window.sorobanApi.manageIdentities( + "rename", + { name: data.from_identity_name }, + data.to_identity_name + ); + + await window.sorobanApi.reloadApplication(); + } catch (error) { + console.error(`Error: ${error}`); + } +} diff --git a/renderer/components/identities/identity-modal.tsx b/renderer/components/identities/identity-modal.tsx new file mode 100644 index 0000000..44ec0b2 --- /dev/null +++ b/renderer/components/identities/identity-modal.tsx @@ -0,0 +1,606 @@ +"use client"; + +import { useState } from "react"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "components/ui/tabs"; +import { Checkbox } from "components/ui/checkbox"; +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import { Loader2 } from "lucide-react"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "components/ui/accordion"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "components/ui/select"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "components/ui/form"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/ui/dialog"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; + +import { + onNewIdentityFormSubmit, + newIdentityFormSchema, +} from "components/identities/forms/createNewIdentity"; + +import { + importIdentityFormSchema, + onimportIdentityFormSubmit, +} from "components/identities/forms/importNewIdentity"; + +import { useToast } from "components/ui/use-toast"; +import { + identityCreateSuccess, + identityCreateError, + identityImportSuccess, + identityImportError, + identityInternetIdentityLoginSuccess, + identityInternetIdentityLoginError, +} from "lib/notifications"; + +export default function IdentityModal({ + showCreateIdentityDialog, + setShowCreateIdentityDialog, +}) { + const [isSubmittingCreateIdentity, setIsSubmittingCreateIdentity] = + useState(false); + const [isSubmittingImportIdentity, setIsSubmittingImportIdentity] = + useState(false); + const [isSubmittingLoginWithII, setIsSubmittingLoginWithII] = useState(false); + + const { toast } = useToast(); + + const handleCreateNewIdentity = async (data) => { + try { + await onNewIdentityFormSubmit(data).then((res) => { + //@ts-ignore + if (res) { + toast(identityCreateSuccess(res)); + setShowCreateIdentityDialog(false); + } + }); + } catch (error) { + // toast(identityCreateError(error)); + console.log(error); + } finally { + setShowCreateIdentityDialog(false); + } + }; + + const handleImportIdentity = async (data) => { + try { + await onimportIdentityFormSubmit(data).then((res) => { + //@ts-ignore + if (res) { + toast(identityImportSuccess(res)); + setShowCreateIdentityDialog(false); + } + }); + } catch (error) { + // toast(identityImportError(error)); + console.log(error); + } finally { + setShowCreateIdentityDialog(false); + } + }; + + const newIdentityForm = useForm>({ + resolver: zodResolver(newIdentityFormSchema), + }); + + const importIdentityForm = useForm>({ + resolver: zodResolver(importIdentityFormSchema), + }); + + async function getDirectoryPath() { + try { + const result = await window.sorobanApi.openDirectory(); + return result; + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + setShowCreateIdentityDialog(false)} + > + + + + New Identity + Import Existing + + +
+ + + Create New Identity + + Identities you will add are global. They are not confined to + a specific project context. + + + +
+
+
+ ( + + + Identity Name + + + + + + + )} + /> +
+ + + Options + +
+
+ ( + + + Seed + + + + + + + )} + /> +
+
+ ( + + + Hd Path + + +
+ + +
+
+ +
+ )} + /> +
+
+ ( + + + + +
+ As Secret + + Output the generated identity as a + secret key + +
+
+ )} + /> +
+
+ ( + + + + +
+ Global + + Use global config + +
+
+ )} + /> +
+
+ ( + + + + +
+ Default Seed + + Generate the default seed phrase. + Useful for testing. Equivalent to + --seed 0000000000000000 + +
+
+ )} + /> +
+
+
+
+ + Testing Options + +
+
+ ( + + + Config Directory + + +
+ + +
+
+ +
+ )} + /> +
+
+
+
+ + Options (RPC) + +
+
+ ( + + + RPC URL + + + + + + + )} + /> +
+
+ ( + + + Network Passphrase + + + + + + + )} + /> +
+
+ ( + + + Network + + + + + + + )} + /> +
+
+
+
+
+
+
+ +
+ + + {isSubmittingCreateIdentity ? ( + + ) : ( + + )} + +
+ +
+ +
+ + + Import Identity + + Create a user identity by importing the user’s key + information or security certificate from a PEM file. + + + +
+
+
+ ( + + + Identity Name + + + + + + + )} + /> +
+
+ ( + + + Pem File + + + + + + + )} + /> +
+ + + Options + +
+
+ ( + + + Storage Mode (Optional) + + + + Plaintext PEM files are still available + (e.g. for use in non-interactive + situations like CI), but not recommended + for use since they put the keys at risk. + + + + )} + /> +
+
+ ( + + + + +
+ Force + + If the identity already exists, remove + and re-import it. + +
+
+ )} + /> +
+
+
+
+
+
+
+
+ + + {isSubmittingImportIdentity ? ( + + ) : ( + + )} + +
+ +
+
+
+
+ ); +} diff --git a/renderer/components/identities/identity-switcher.tsx b/renderer/components/identities/identity-switcher.tsx new file mode 100644 index 0000000..719d194 --- /dev/null +++ b/renderer/components/identities/identity-switcher.tsx @@ -0,0 +1,251 @@ +"use client"; + +import * as React from "react"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { + CaretSortIcon, + CheckIcon, + PlusCircledIcon, + UpdateIcon, +} from "@radix-ui/react-icons"; +import { cn } from "lib/utils"; +import { Avatar, AvatarFallback, AvatarImage } from "components/ui/avatar"; +import { Button } from "components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "components/ui/command"; +import { Dialog, DialogTrigger } from "components/ui/dialog"; +import { Popover, PopoverContent, PopoverTrigger } from "components/ui/popover"; + +import IdentityModal from "components/identities/identity-modal"; + +const initialGroups = [ + { + label: "Active Identity", + teams: [ + { + label: "", + value: "", + }, + ], + }, + { + label: "Identities", + teams: [ + { + label: "", + value: "", + }, + ], + }, +]; + +function showFirst3Last4(str) { + // Ensure the string is long enough + if (str.length > 10) { + const first3 = str.substring(0, 4); + const last4 = str.substring(str.length - 6); + return `${first3}...${last4}`; + } + return str; +} + +type Team = (typeof initialGroups)[number]["teams"][number]; + +type PopoverTriggerProps = React.ComponentPropsWithoutRef< + typeof PopoverTrigger +>; + +interface TeamSwitcherProps extends PopoverTriggerProps {} + +export default function IdentitySwitcher({ className }: TeamSwitcherProps) { + const [open, setOpen] = React.useState(false); + const [showNewTeamDialog, setShowNewTeamDialog] = React.useState(false); + const [selectedIdentity, setSelectedIdentity] = React.useState( + initialGroups[0].teams[0] + ); + const [updatedGroups, setUpdatedGroups] = useState(initialGroups); + + const router = useRouter(); + + async function checkCurrentIdentity() { + try { + const result = await window.sorobanApi.runSorobanCommand( + "config", + "identity", + ["address"] + ); + + initialGroups[0].teams[0].label = result; + initialGroups[0].teams[0].value = result; + setSelectedIdentity({ + label: showFirst3Last4(result), + value: result, + }); + + console.log(); + } catch (error) { + console.log("Error invoking remote method:", error); + } + } + + async function checkIdentities() { + try { + await window.sorobanApi.refreshIdentities(); + await window.sorobanApi.manageIdentities("list", ""); + + const identities = await window.sorobanApi.manageIdentities("list", ""); + + const newGroups = updatedGroups.map((group) => { + if (group.label === "Identities") { + return { + ...group, + teams: identities.map((identity) => ({ + label: showFirst3Last4(identity.name), + value: identity.name, + })), + }; + } + return group; + }); + + // Update the state variable with the new groups data + setUpdatedGroups(newGroups); + } catch (error) { + console.log("Error invoking remote method:", error); + } + } + + async function changeIdentity(newIdentity: string) { + try { + await window.sorobanApi.runSorobanCommand("identity", "use", [ + newIdentity, + ]); + } catch (error) { + console.log("Error invoking remote method:", error); + } + } + + const hasIdentities = updatedGroups.some((group) => group.teams.length > 0); + + useEffect(() => { + checkCurrentIdentity(); + checkIdentities(); + }, []); + + return ( + setShowNewTeamDialog(false)} + > + + + + + + + + + No identity found. + {hasIdentities && + updatedGroups.map((group) => ( + + {group.teams.map((team) => ( + { + if (selectedIdentity.value !== team.value) { + setSelectedIdentity(team); + changeIdentity(team.value); + } + setOpen(false); + }} + className="text-sm" + > + + + SC + + {showFirst3Last4(team.label)} + + + ))} + + ))} + + + + + + { + setOpen(false); + setShowNewTeamDialog(true); + }} + > + + Create Identity + + + + + + + + { + setOpen(false); + router.push("/identities"); + }} + > + + Edit Identities + + + + + + + + + ); +} diff --git a/renderer/components/identities/no-identities.tsx b/renderer/components/identities/no-identities.tsx new file mode 100644 index 0000000..8c48fd0 --- /dev/null +++ b/renderer/components/identities/no-identities.tsx @@ -0,0 +1,13 @@ +export default function NoIdentities() { + return ( +
+ {" "} +
+

+ No identities found, create, login or add existing identities by + clicking the button above +

+
+
+ ); +} diff --git a/renderer/components/layout.tsx b/renderer/components/layout.tsx new file mode 100644 index 0000000..bd12413 --- /dev/null +++ b/renderer/components/layout.tsx @@ -0,0 +1,149 @@ +// Import necessary components and hooks +import * as React from "react"; +import { useRouter } from "next/router"; +import Image from "next/image"; +import { SideNav } from "components/sidebar-nav"; +import { ThemeProvider } from "components/theme-provider"; +import { ModeToggle } from "components/toggle-mode"; +import { ReloadToggle } from "components/toggle-reload"; +import IdentitySwitcher from "components/identities/identity-switcher"; +import { Toaster } from "components/ui/toaster"; +import { cn } from "lib/utils"; +import { TooltipProvider } from "components/ui/tooltip"; +import { Separator } from "components/ui/separator"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "components/ui/resizable"; + +import { + HomeIcon, + DatabaseIcon, + NetworkIcon, + CircuitBoardIcon, + SettingsIcon, +} from "lucide-react"; + +interface LayoutProps { + children: React.ReactNode; +} + +export default function Layout({ children }: LayoutProps) { + const router = useRouter(); + + // Set initial layout and collapsed state + const defaultLayout = [15, 85]; + const [isCollapsed, setIsCollapsed] = React.useState(false); + + const navCollapsedSize = 4; + + const handleCollapse = React.useCallback(() => { + setIsCollapsed((prevState) => !prevState); // Toggle the collapsed state + document.cookie = `react-resizable-panels:collapsed=${JSON.stringify( + isCollapsed + )}`; + }, []); + + return ( + +
+ {" "} +
+ icp_logo +
+ + + +
+
+ + { + document.cookie = `react-resizable-panels:layout=${JSON.stringify( + sizes + )}`; + }} + className="h-full items-stretch" + > + +
+
+ + +
+
+
+ + +
{children}
+
+
+
+
+ +
+ ); +} diff --git a/renderer/components/projects/Projects.tsx b/renderer/components/projects/Projects.tsx new file mode 100644 index 0000000..b35ee0f --- /dev/null +++ b/renderer/components/projects/Projects.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; + +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "components/ui/form"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/ui/dialog"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "components/ui/card"; + +import { Avatar, AvatarImage } from "components/ui/avatar"; +import { Alert, AlertDescription, AlertTitle } from "components/ui/alert"; +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import { Loader2 } from "lucide-react"; +import { FolderCheck } from "lucide-react"; + +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; + +import { CodeIcon } from "lucide-react"; +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; +import ProjectModal from "components/projects/project-modal"; +import NoProjects from "components/projects/no-project"; + +import { + removeProjectFormSchema, + onRemoveProjectFormSubmit, +} from "components/projects/forms/removeProject"; + +import { useToast } from "components/ui/use-toast"; +import { projectRemoveSuccess, projectRemoveError } from "lib/notifications"; + +const ProjectCard = ({ + project, + onProjectChange, +}: { + project: { + name: string; + path: string; + active: boolean; + }; + onProjectChange: () => void; +}) => { + const [showRemoveProjectDialog, setShowRemoveProjectDialog] = useState(false); + const [isSubmittingRemoveProject, setIsSubmittingRemoveProject] = + useState(false); + + const { toast } = useToast(); + + const removeProjectForm = useForm>({ + resolver: zodResolver(removeProjectFormSchema), + defaultValues: { + project_name: project.name, + path: project.path, + }, + }); + + const handleRemoveProjectFormSubmit = async (data) => { + setIsSubmittingRemoveProject(true); + try { + await onRemoveProjectFormSubmit(data).then(() => { + toast(projectRemoveSuccess(data.project_name)); + setShowRemoveProjectDialog(false); + removeProjectForm.reset(); + onProjectChange(); + }); + } catch (error) { + console.error(error); + } finally { + setIsSubmittingRemoveProject(false); + } + }; + + return ( + + +
+ + + +
+ {project.name} + + + {project.path.split("/").slice(-2)[0] + + "/" + + project.path.split("/").slice(-2)[1]} + +
+
+
+ + + + + + setShowRemoveProjectDialog(false)} + > + +
+ + + Remove "{project.name}" + + You can remove your project on application, this doesn't + remove your project folder on your system. + + +
+
+
+ ( + + + Project Name + + + + + + + )} + /> +
+
+ ( + + Path + + + + + + )} + /> +
+
+
+ + + {isSubmittingRemoveProject ? ( + + ) : ( + + )} + +
+ +
+
+
+
+ ); +}; + +export default function ProjectsComponent() { + const [showCreateProjectDialog, setShowCreateProjectDialog] = useState(false); + const [projects, setProjects] = useState(); + const [searchQuery, setSearchQuery] = useState(""); + + async function checkProjects() { + try { + const projects = await window.sorobanApi.manageProjects("get", ""); + + setProjects(projects); + } catch (error) { + console.log("Error invoking remote method:", error); + } + } + + const refreshProjects = async () => { + await checkProjects(); + }; + + const handleSearchChange = (e: any) => { + e.preventDefault(); + setSearchQuery(e.target.value); + }; + + useEffect(() => { + checkProjects(); + }, []); + + return ( +
+
+ +
+ +
+ + You have {projects?.length ? projects?.length : "0"} projects + + + You can add, remove, or edit your projects on this page. + +
+
+ +
+ +
+ {projects?.length > 0 ? ( +
+
+ +
+ +
+ {projects + .filter((project) => + project.name.toLowerCase().includes(searchQuery.toLowerCase()) + ) + .map((project) => ( + + ))} +
+ +
+
+ ) : ( + + )} +
+ ); +} diff --git a/renderer/components/projects/forms/addExistingProject.ts b/renderer/components/projects/forms/addExistingProject.ts new file mode 100644 index 0000000..82ec73e --- /dev/null +++ b/renderer/components/projects/forms/addExistingProject.ts @@ -0,0 +1,36 @@ +import * as z from "zod"; + +export const addExistingProjectFormSchema = z.object({ + project_name: z + .string() + .min(3, { + message: "Project name must be at least 3 characters long", + }) + .max(255), + path: z + .string() + .min(3, { + message: "You must select a path", + }) + .max(255), +}); + +export async function onAddExistingProjectForm( + data: z.infer +) { + try { + //check if it is a dfx project + const result = await window.sorobanApi + .isSorobanProject(data.path) + .then(async () => { + await window.sorobanApi.manageProjects("add", { + name: data.project_name, + path: data.path, + }); + }); + + return result; + } catch (error) { + console.error(`Error: ${error}`); // log error + } +} diff --git a/renderer/components/projects/forms/createNewProject.ts b/renderer/components/projects/forms/createNewProject.ts new file mode 100644 index 0000000..9ec9922 --- /dev/null +++ b/renderer/components/projects/forms/createNewProject.ts @@ -0,0 +1,47 @@ +import * as z from "zod"; + +export const createNewProjectFormSchema = z.object({ + project_name: z + .string() + .min(3, { + message: "Project name must be at least 3 characters long", + }) + .max(255), + path: z + .string() + .min(3, { + message: "You must select a path", + }) + .max(255), + frontend_status: z.boolean().optional(), + dry_run: z.boolean().optional(), +}); + +export async function onCreateNewProjectForm( + data: z.infer +) { + try { + const command = "new"; + const subcommand = null; + const args = [data.project_name]; + const flags = [ + data.dry_run ? `--dry-run` : null, + data.frontend_status ? `--frontend` : null, + ].filter(Boolean); // This will remove any null values from the array + + console.log(command, subcommand, args, flags); + + const result = await window.sorobanApi + .runSorobanCommand(command, subcommand, args, flags, data.path) + .then(async () => { + await window.sorobanApi.manageProjects("add", { + name: data.project_name, + path: data.path + "/" + data.project_name, + }); + }); + + return result; + } catch (error) { + console.error(`Error: ${error}`); // log error + } +} diff --git a/renderer/components/projects/forms/removeProject.ts b/renderer/components/projects/forms/removeProject.ts new file mode 100644 index 0000000..010bbd9 --- /dev/null +++ b/renderer/components/projects/forms/removeProject.ts @@ -0,0 +1,23 @@ +import * as z from "zod"; + +export const removeProjectFormSchema = z.object({ + project_name: z + .string() + .min(3, "Project name must be at least 3 characters long.") + .max(50, "Project name must be at most 50 characters long."), + path: z.string(), +}); + +export async function onRemoveProjectFormSubmit( + data: z.infer +) { + try { + const result = await window.sorobanApi.manageProjects("delete", { + path: data.path, + }); + + return result; + } catch (error) { + console.error(`Error: ${error}`); // log error + } +} diff --git a/renderer/components/projects/forms/renameProject.ts b/renderer/components/projects/forms/renameProject.ts new file mode 100644 index 0000000..eaaab7d --- /dev/null +++ b/renderer/components/projects/forms/renameProject.ts @@ -0,0 +1,45 @@ +import * as z from "zod"; + +export const renameProjectFormSchema = z.object({ + from_project_name: z + .string() + .min(3, "Project name must be at least 3 characters long.") + .max(50, "Project name must be at most 50 characters long."), + to_project_name: z + .string() + .min(3, "Project name must be at least 3 characters long.") + .max(50, "Project name must be at most 50 characters long.") + .regex( + /^[A-Za-z0-9.\-_@]+$/, + "Only the characters ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz.-_@0123456789 are valid in project names." + ), + path: z.string(), +}); + +export async function onRenameProjectFormSubmit( + data: z.infer +) { + try { + const { to_project_name, path } = data; + + const existingProject = await window.sorobanApi.manageProjects("get", { + path: path, + }); + + if (!existingProject) { + throw new Error("Project not found"); + } + + const updatedProject = { ...existingProject, name: to_project_name }; + + const result = await window.sorobanApi.manageProjects( + "update", + updatedProject + ); + + return result; + } catch (error) { + console.error(`Error renaming project: ${error}`); + throw error; // Rethrow error to be handled by the caller + } +} diff --git a/renderer/components/projects/no-project.tsx b/renderer/components/projects/no-project.tsx new file mode 100644 index 0000000..25e4741 --- /dev/null +++ b/renderer/components/projects/no-project.tsx @@ -0,0 +1,10 @@ +export default function NoProjects() { + return ( +
+ {" "} +
+

No project found, create a new one

+
+
+ ); +} diff --git a/renderer/components/projects/project-modal.tsx b/renderer/components/projects/project-modal.tsx new file mode 100644 index 0000000..44abb00 --- /dev/null +++ b/renderer/components/projects/project-modal.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { useState } from "react"; + +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "components/ui/form"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogTrigger, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/ui/dialog"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; + +import { Switch } from "components/ui/switch"; +import { ScrollArea, ScrollBar } from "components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "components/ui/tabs"; +import { Button } from "components/ui/button"; +import { Input } from "components/ui/input"; +import { Loader2 } from "lucide-react"; + +import { + createNewProjectFormSchema, + onCreateNewProjectForm, +} from "components/projects/forms/createNewProject"; + +import { + addExistingProjectFormSchema, + onAddExistingProjectForm, +} from "components/projects/forms/addExistingProject"; + +import { useToast } from "components/ui/use-toast"; +import { + projectCreateSuccess, + projectCreateError, + projectImportSuccess, + projectImportError, +} from "lib/notifications"; + +export default function ProjectModal({ + showNewProjectDialog, + setShowNewProjectDialog, + onProjectChange, +}) { + const [isSubmittingNewProject, setIsSubmittingNewProject] = useState(false); + const [isSubmittingExistingProject, setIsSubmittingExistingProject] = + useState(false); + + const { toast } = useToast(); + + const createNewProjectform = useForm< + z.infer + >({ + resolver: zodResolver(createNewProjectFormSchema), + defaultValues: { + frontend_status: true, + dry_run: false, + }, + }); + + const addExistingProjectForm = useForm< + z.infer + >({ + resolver: zodResolver(addExistingProjectFormSchema), + }); + + // Modify your form submit handler to use setIsSubmitting + const handleNewProjectFormSubmit = async (data) => { + setIsSubmittingNewProject(true); + try { + await onCreateNewProjectForm(data).then(() => { + toast(projectCreateSuccess(data.project_name)); + setShowNewProjectDialog(false); + createNewProjectform.reset(); + onProjectChange(); + }); + } catch (error) { + toast(projectCreateError(data.project_name, error)); + console.log(error); + } finally { + setIsSubmittingNewProject(false); + } + }; + + const handleExistingProjectFormSubmit = async (data) => { + setIsSubmittingExistingProject(true); + try { + const result = await window.sorobanApi.isSorobanProject( + data.path as string + ); + + console.log(result); + + if (result) { + await onAddExistingProjectForm(data).then(async () => { + toast(projectImportSuccess(data.project_name)); + setShowNewProjectDialog(false); + addExistingProjectForm.reset(); + onProjectChange(); + }); + } else { + toast(projectImportError(data.project_name)); + setShowNewProjectDialog(false); + addExistingProjectForm.reset(); + onProjectChange(); + } + } catch (error) { + console.error(error); + } finally { + setIsSubmittingExistingProject(false); + } + }; + + async function getDirectoryPath() { + try { + const result = await window.sorobanApi.openDirectory(); + return result; + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + setShowNewProjectDialog(false)} + > + + + + New Project + Import Existing + + +
+ + + Create Project + + Create a new project for the Internet Computer + + + +
+
+
+ ( + + + Project Name + + + + + + + )} + /> +
+
+ ( + + + Project Path + + +
+ + +
+
+ +
+ )} + /> +
+
+ Options + ( + +
+ + Activate Frontend + + + Installs the template frontend code for the + default project canister. + +
+ + + +
+ )} + /> +
+
+
+ +
+ + + {isSubmittingNewProject ? ( + + ) : ( + + )} + +
+ +
+ +
+ + + Import Existing Project + + Create a new project for the Internet Computer + + +
+
+
+ ( + + + Project Name + + + + + + + )} + /> +
+
+ ( + + + Project Path + + +
+ + +
+
+ +
+ )} + /> +
+
+
+ + + {isSubmittingExistingProject ? ( + + ) : ( + + )} + +
+ +
+
+
+
+ ); +} diff --git a/renderer/components/settings/Settings.tsx b/renderer/components/settings/Settings.tsx new file mode 100644 index 0000000..58fa946 --- /dev/null +++ b/renderer/components/settings/Settings.tsx @@ -0,0 +1,25 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from "components/ui/tabs"; +import { Separator } from "components/ui/separator"; + +export default function SettingsComponent() { + return ( + +
+

Settings

+ {/* + Environment Variables + + Adapters + + */} +
+ + +

asdasdasd

+
+ {/* + + */} +
+ ); +} diff --git a/renderer/components/sidebar-nav.tsx b/renderer/components/sidebar-nav.tsx new file mode 100644 index 0000000..48ccd8f --- /dev/null +++ b/renderer/components/sidebar-nav.tsx @@ -0,0 +1,86 @@ +"use client"; + +import Link from "next/link"; +import { cn } from "lib/utils"; +import { buttonVariants } from "components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "components/ui/tooltip"; + +interface NavProps { + isCollapsed: boolean; + links: { + title: string; + label?: string; + icon: any; // Ideally, specify a more precise type + variant: "default" | "ghost"; + href: string; // Assuming each link has an href property + onClick?: () => void; // Assuming you might have onClick handlers + }[]; +} + +export function SideNav({ links, isCollapsed }: NavProps) { + return ( +
+ +
+ ); +} diff --git a/renderer/components/theme-provider.tsx b/renderer/components/theme-provider.tsx new file mode 100644 index 0000000..b0ff266 --- /dev/null +++ b/renderer/components/theme-provider.tsx @@ -0,0 +1,9 @@ +"use client"; + +import * as React from "react"; +import { ThemeProvider as NextThemesProvider } from "next-themes"; +import { type ThemeProviderProps } from "next-themes/dist/types"; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/renderer/components/toggle-mode.tsx b/renderer/components/toggle-mode.tsx new file mode 100644 index 0000000..034a6e5 --- /dev/null +++ b/renderer/components/toggle-mode.tsx @@ -0,0 +1,43 @@ +"use client"; + +import * as React from "react"; +import { useTheme } from "next-themes"; + +import { Button } from "components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "components/ui/dropdown-menu"; +import { Icons } from "components/icons"; + +export function ModeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + + Light + + setTheme("dark")}> + + Dark + + setTheme("system")}> + + System + + + + ); +} diff --git a/renderer/components/toggle-reload.tsx b/renderer/components/toggle-reload.tsx new file mode 100644 index 0000000..2d77910 --- /dev/null +++ b/renderer/components/toggle-reload.tsx @@ -0,0 +1,24 @@ +"use client"; + +import * as React from "react"; + +import { Button } from "components/ui/button"; +import { Icons } from "components/icons"; + +export function ReloadToggle() { + async function reloadApplication() { + try { + await window.sorobanApi.reloadApplication(); + } catch (error) { + console.error(`Error: ${error}`); + } + } + + return ( + + ); +} diff --git a/renderer/components/ui/accordion.tsx b/renderer/components/ui/accordion.tsx new file mode 100644 index 0000000..3a794af --- /dev/null +++ b/renderer/components/ui/accordion.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/renderer/components/ui/alert-dialog.tsx b/renderer/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..2f697e2 --- /dev/null +++ b/renderer/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "lib/utils"; +import { buttonVariants } from "components/ui/button"; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = "AlertDialogHeader"; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = "AlertDialogFooter"; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/renderer/components/ui/alert.tsx b/renderer/components/ui/alert.tsx new file mode 100644 index 0000000..1ecfc4a --- /dev/null +++ b/renderer/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "lib/utils"; + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/renderer/components/ui/avatar.tsx b/renderer/components/ui/avatar.tsx new file mode 100644 index 0000000..61b224c --- /dev/null +++ b/renderer/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import * as AvatarPrimitive from "@radix-ui/react-avatar"; + +import { cn } from "lib/utils"; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/renderer/components/ui/button.tsx b/renderer/components/ui/button.tsx new file mode 100644 index 0000000..2601bd3 --- /dev/null +++ b/renderer/components/ui/button.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/renderer/components/ui/card.tsx b/renderer/components/ui/card.tsx new file mode 100644 index 0000000..28da0ca --- /dev/null +++ b/renderer/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/renderer/components/ui/checkbox.tsx b/renderer/components/ui/checkbox.tsx new file mode 100644 index 0000000..9776db9 --- /dev/null +++ b/renderer/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +import { cn } from "lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/renderer/components/ui/command.tsx b/renderer/components/ui/command.tsx new file mode 100644 index 0000000..9da8f82 --- /dev/null +++ b/renderer/components/ui/command.tsx @@ -0,0 +1,153 @@ +import * as React from "react"; +import { type DialogProps } from "@radix-ui/react-dialog"; +import { Command as CommandPrimitive } from "cmdk"; +import { Search } from "lucide-react"; + +import { cn } from "lib/utils"; +import { Dialog, DialogContent } from "components/ui/dialog"; + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/renderer/components/ui/dialog.tsx b/renderer/components/ui/dialog.tsx new file mode 100644 index 0000000..043205c --- /dev/null +++ b/renderer/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; + +import { cn } from "lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/renderer/components/ui/dropdown-menu.tsx b/renderer/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..d0f6815 --- /dev/null +++ b/renderer/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/renderer/components/ui/form.tsx b/renderer/components/ui/form.tsx new file mode 100644 index 0000000..75bef16 --- /dev/null +++ b/renderer/components/ui/form.tsx @@ -0,0 +1,177 @@ +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; + +import { cn } from "lib/utils"; +import { Label } from "components/ui/label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +