diff --git a/.gitignore b/.gitignore index aadc1a3..477311f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,9 +24,11 @@ scripts/*.yml #Electron-builder output /dist_electron .serena/ +.claude/ dist/* test.js specs/ .cache/ openspec/ -bug* \ No newline at end of file +bug* +Trace-*.json \ No newline at end of file diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..66cf6b3 --- /dev/null +++ b/.ignore @@ -0,0 +1 @@ +!openspec/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4bf56a5..9dfe27a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ PicGo is an Electron + Vue 3 desktop client. Source lives in `src/`: `src/main` - `pnpm lint:dpdm` — fail fast on circular dependencies in `src/`. - `pnpm check` — run `tsc` + `lint` (run once before finishing a task). - Before completing a task, always run `pnpm check` and resolve any issues it reports. -- `pnpm gen-i18n` — regenerate typed locales after touching `public/i18n/*.yml`. +- i18n type files are auto-generated by the Vite `i18nTypesPlugin` when `public/i18n/*.yml` changes. Do not add or rely on a manual `gen-i18n` step. ## Coding Style & Naming Conventions Follow ESLint Standard defaults: two-space indentation, single quotes, trailing commas where allowed, and no stray semicolons. Author new modules in TypeScript. Keep renderer files browser-safe; route Node APIs through IPC helpers such as `src/main/events/picgoCoreIPC.ts`. Name Vue components in PascalCase (`UploadPanel.vue`) and use camelCase for utilities. Centralize IPC event names inside `src/universal/events/constants.ts`, and store enums/types under `src/universal/types/` so they stay reusable. Static assets are served from `public/` and resolved via `getStaticPath`/`getStaticFileUrl` (`src/universal/utils/staticPath.ts`); avoid using `__static` directly. @@ -21,11 +21,40 @@ Static assets are served from `public/`. In the main process use `getStaticPath` - Do not use `as any` under any circumstances; keep typings explicit and safe. - Avoid `as any` in tests as well; build concrete typed stubs (e.g., `IpcMainInvokeEvent`) instead. - Do not prefix method calls with `void` (e.g. use `store?.refreshPicBeds()` rather than `void store?.refreshPicBeds()`). +- Do not write `void someMethod()` or `void object.method()` anywhere in the codebase. If you need fire-and-forget behavior, use an `async` callback with `await`, or handle errors explicitly with `try/catch` and logging instead of swallowing them. +- Because the renderer uses React Compiler, do not add `useMemo` or `useCallback` by default. Prefer plain values and inline functions unless a specific API requires stable identity or there is a proven performance issue. +- For Zustand store state/actions, prefer reading them directly in the consuming component with `useAppStore` / `useStore` instead of passing them down through unnecessary prop layers. If a child can access the needed store value or action itself, do not thread it through parent props. +- If a child component only needs a store action (for example `providerStoreActions.toggleExpanded`, `settingsStoreActions.setSearchValue`, or `galleryStoreActions.setViewMode`), do not pass that store action through props. Import and use the action directly inside the child component. +- Renderer Zustand stores must follow the project store architecture: + - `src/renderer/store/app-store.ts` is for true global renderer state only. + - Feature/page-local UI state must live under `src/renderer/store//` (for example `src/renderer/store/gallery/store.ts`, `src/renderer/store/gallery/actions.ts`), not inside `app-store.ts`. + - Do not add an extra nested `store/` directory like `src/renderer/store/gallery/store/store.ts`. +- Zustand state and actions must be separated: + - Do not define state-mutating actions inside `create()`. + - Keep store files focused on state shape and initial state. + - Put global actions in `src/renderer/store/app-actions.ts`. + - Put feature actions in `src/renderer/store//actions.ts`. + - Actions must update state via `useXxxStore.setState(...)`. +- Follow strict IPC boundaries for Zustand actions: + - Pure IPC/service calls without Zustand state changes should call the adapter/service directly from the component or helper, not through a Zustand action. + - Flows that combine IPC/service work with Zustand state updates must live in actions files. +- Keep server state and client state separated: + - TanStack Query should own server state: remote API data, loading/error/stale status, refetching, cache, and request dedupe. + - Zustand should own client/UI state: selected source, selected ids, filters, view mode, panel open state, and other local user intent. + - Connect the two with ids, query params, and local UI state (for example `albumSource`, `typeFilter`, or `searchValue`), but do not mirror query response data back into Zustand. + - If server state invalidates a local UI choice (for example a user becomes non-paid while `albumSource` is cloud), use a small effect/action to correct the local UI state instead of storing the whole server response in Zustand. +- When updating nested Zustand state (especially config-like objects), use `zustand/middleware/immer`; do not reintroduce deep `...state` spread chains for nested updates. +- Components must consume Zustand state through auto-generated selectors (for example `useAppStore.use.appConfig()`), not by destructuring the whole store or writing ad-hoc hook selectors in components. +- Renderer-side shared constants (for example responsive breakpoints, UI timing values, fixed dimensions, thresholds, and repeated literal values used across components) should be centralized in `src/renderer/utils/consts.ts` instead of being hardcoded inline in components. When a new renderer constant may be reused or affects shared behavior, add it there first. +- Renderer-side date/time formatting should use `dayjs` and shared format constants from `src/renderer/utils/consts.ts` (for example `DEFAULT_DATE_TIME_FORMAT`) instead of `Intl.DateTimeFormat` or ad-hoc inline format strings. +- Enum-like object constants declared with `as const` (for example status maps, option maps, and value registries) must use PascalCase names, not camelCase. Prefer names like `PicGoCloudRequestStatusValues`, `SettingsAppearanceValues`, or `AppPlatformValues`. - If a renderer → main request mutates persisted config/state without using `saveConfig`, call `notifyAppConfigUpdated()` in main to inform renderers. - Prefer enums over union types for discrete value sets (e.g., encryption methods). Avoid introducing new string literal union types. - Renderer page/component styles should prefer Tailwind utility classes; avoid adding new Vue ` diff --git a/src/renderer/adapters/album.ts b/src/renderer/adapters/album.ts new file mode 100644 index 0000000..0781c0a --- /dev/null +++ b/src/renderer/adapters/album.ts @@ -0,0 +1,42 @@ +import { PASTE_TEXT } from '#/events/constants' +import db from '@/utils/db' +import { sendToMain } from '@/utils/dataSender' +import { clipboard, ipc } from '@/utils/bridge' + +import { resolveTimestampValue } from '@/utils/common' + +function resolveAlbumItemTimestamp (item: ImgInfo) { + return resolveTimestampValue(item.createdAt) || resolveTimestampValue(item.updatedAt) +} + +export const albumAdapter = { + async getAlbumItems () { + const result = await db.get({ orderBy: 'desc' }) + return result.data + }, + async getRecentUploads (limit = 100) { + const result = await db.get({ orderBy: 'desc' }) + return [...result.data] + .sort((left, right) => resolveAlbumItemTimestamp(right) - resolveAlbumItemTimestamp(left)) + .slice(0, limit) + }, + async updateImageUrl (id: string, imgUrl: string) { + await db.updateById(id, { imgUrl }) + }, + async updateImportFlag (id: string, imported: boolean) { + await db.updateById(id, { _importToPicGoCloud: imported }) + }, + async removeById (id: string) { + const file = await db.getById(id) + await db.removeById(id) + if (file) { + sendToMain('removeFiles', [file]) + } + }, + async copyImageLink (item: ImgInfo) { + return ipc.invoke(PASTE_TEXT, item) + }, + copyBatchLinks (links: string[]) { + clipboard.writeText(links.join('\n')) + } +} diff --git a/src/renderer/adapters/app-config.ts b/src/renderer/adapters/app-config.ts new file mode 100644 index 0000000..3c651b0 --- /dev/null +++ b/src/renderer/adapters/app-config.ts @@ -0,0 +1,12 @@ +import type { IConfig } from 'picgo' +import { getConfig, getPicBeds, saveConfig } from '@/utils/dataSender' + +export const appConfigAdapter = { + getAppConfig () { + return getConfig() + }, + getPicBeds () { + return getPicBeds() + }, + saveConfig +} diff --git a/src/renderer/adapters/cloud-album.ts b/src/renderer/adapters/cloud-album.ts new file mode 100644 index 0000000..d4cd5e4 --- /dev/null +++ b/src/renderer/adapters/cloud-album.ts @@ -0,0 +1,83 @@ +import { toast } from 'sonner' +import { UserPlanLevel, type IPicGoCloudBillingOverview, type IPicGoCloudUserInfo } from '#/types/cloud' +import type { + CloudAlbumBatchUpdateResult, + CloudAlbumFiltersResponse, + CloudAlbumImportAllResult, + CloudAlbumImportResult, + CloudAlbumListQuery, + CloudAlbumListResponse, + CloudAlbumStatsResponse +} from '#/types/cloudAlbum' +import { IRPCActionType } from '#/types/enum' +import { PicGoCloudBillingQueryKeys } from '@/queries/picgo-cloud-billing' +import { resolveCloudErrorMessage } from '@/utils/cloud-error' +import { + mergePicGoCloudUserInfoQueryData, + PicGoCloudQueryKeys +} from '@/queries/picgo-cloud' +import { rendererQueryClient } from '@/queries/query-client' +import { invokeRPC } from '@/utils/dataSender' +import i18n from '@/i18n' + +export const cloudAlbumAdapter = { + async list (query?: CloudAlbumListQuery) { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_ALBUM_LIST, query) + }, + async deleteItems (ids: string | string[]) { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_ALBUM_DELETE, ids) + }, + async updateItem (id: string, data: Partial) { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_ALBUM_UPDATE, id, data) + }, + async batchUpdate (items: { id: string, data: Partial }[]) { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_ALBUM_BATCH_UPDATE, items) + }, + async importItems (items: ImgInfo[]) { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_ALBUM_IMPORT, items) + }, + async getStats () { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_ALBUM_GET_STATS) + }, + async getFilters () { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_ALBUM_GET_FILTERS) + }, + async setAutoImport (autoImport: boolean) { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_SET_AUTO_IMPORT, autoImport) + }, + async importAllItems () { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_ALBUM_IMPORT_ALL) + } +} + +export async function handleCloudImportAll (onSuccess?: () => void): Promise { + try { + const currentUserInfo = rendererQueryClient.getQueryData( + PicGoCloudQueryKeys.userInfo + ) + if ((currentUserInfo?.plan ?? UserPlanLevel.Free) <= UserPlanLevel.Free) { + return + } + + const result = await cloudAlbumAdapter.importAllItems() + if (result.success) { + // Only merge partial fields (e.g. autoImport) — the RPC response + // may not include plan, so a full replace would lose it. + if (result.data.userInfo) { + mergePicGoCloudUserInfoQueryData(result.data.userInfo) + } + if (result.data.created > 0) { + toast.success(i18n.t('ALBUM_CLOUD_IMPORT_SUCCESS', { num: String(result.data.created) })) + } + onSuccess?.() + } else { + const billing = rendererQueryClient.getQueryData( + PicGoCloudBillingQueryKeys.overview + ) + toast.error(resolveCloudErrorMessage(i18n.t, result.code, billing?.lifecycle?.phase, result.error)) + } + } catch (error) { + console.error(error) + toast.error(i18n.t('OPERATION_FAILED')) + } +} diff --git a/src/renderer/adapters/cloud.ts b/src/renderer/adapters/cloud.ts new file mode 100644 index 0000000..8c6b66b --- /dev/null +++ b/src/renderer/adapters/cloud.ts @@ -0,0 +1,79 @@ +import type { IPicGoCloudBillingOverview, IPicGoCloudUsage, IPicGoCloudUserInfo } from '#/types/cloud' +import type { + IPicGoCloudConfigSyncResolution, + IPicGoCloudConfigSyncRunResult, + IPicGoCloudConfigSyncState, + IPicGoCloudEncryptionMethod +} from '#/types/cloudConfigSync' +import { IRPCActionType } from '#/types/enum' +import { invokeRPC, openURL } from '@/utils/dataSender' + +export const PICGO_CLOUD_URL = 'https://cloud.picgo.app' +export const PICGO_CLOUD_PRICING_URL = 'https://cloud.picgo.app/pricing' +export const PICGO_CLOUD_TERMS_URL = 'https://picgo.app/terms/' +export const PICGO_CLOUD_PRIVACY_URL = 'https://picgo.app/privacy/' +export const PICGO_CLOUD_ENCRYPTION_DOC_URL = 'https://docs.picgo.app/gui/guide/picgo-cloud#encryption-mode' + +type PicGoCloudUserInfoOptions = { + refresh?: boolean +} + +export const cloudAdapter = { + async getUserInfo (options?: PicGoCloudUserInfoOptions) { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_GET_USER_INFO, options) + }, + async getUsage () { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_GET_USAGE) + }, + async getBillingOverview () { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_GET_BILLING_OVERVIEW) + }, + async login () { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_LOGIN) + }, + async logout () { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_LOGOUT) + }, + async disposeLoginFlow () { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_DISPOSE_LOGIN_FLOW) + }, + async getConfigSyncState () { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_GET_STATE) + }, + async startConfigSync () { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_START) + }, + async abortConfigSync () { + return await invokeRPC(IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_ABORT) + }, + async applyConfigSyncResolution (resolution: IPicGoCloudConfigSyncResolution) { + return await invokeRPC( + IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_APPLY_RESOLUTION, + resolution + ) + }, + async setEncryptionMethod (mode: IPicGoCloudEncryptionMethod) { + return await invokeRPC( + IRPCActionType.PICGO_CLOUD_CONFIG_SYNC_SET_E2E_PREFERENCE, + mode + ) + }, + async reloadApp () { + return await invokeRPC(IRPCActionType.RELOAD_APP) + }, + openCloud () { + openURL(PICGO_CLOUD_URL) + }, + openPricing () { + openURL(PICGO_CLOUD_PRICING_URL) + }, + openTerms () { + openURL(PICGO_CLOUD_TERMS_URL) + }, + openPrivacy () { + openURL(PICGO_CLOUD_PRIVACY_URL) + }, + openEncryptionDocs () { + openURL(PICGO_CLOUD_ENCRYPTION_DOC_URL) + } +} diff --git a/src/renderer/adapters/dashboard.ts b/src/renderer/adapters/dashboard.ts new file mode 100644 index 0000000..16f0095 --- /dev/null +++ b/src/renderer/adapters/dashboard.ts @@ -0,0 +1,22 @@ +import { LOG_INVALID_URL_LINES, SHOW_UPLOAD_PAGE_MENU } from '#/events/constants' +import { saveConfig, sendToMain } from '@/utils/dataSender' + +export const dashboardAdapter = { + uploadSelectedFiles (files: IFileWithPath[]) { + sendToMain('uploadChoosedFiles', files) + }, + uploadClipboardFiles () { + sendToMain('uploadClipboardFilesFromUploadPage') + }, + openUploaderMenu () { + sendToMain(SHOW_UPLOAD_PAGE_MENU) + }, + logInvalidUrlLines (lines: string[]) { + sendToMain(LOG_INVALID_URL_LINES, lines) + }, + savePasteStyle (pasteStyle: string) { + return saveConfig({ + 'settings.pasteStyle': pasteStyle + }) + } +} diff --git a/src/renderer/adapters/main-more.ts b/src/renderer/adapters/main-more.ts new file mode 100644 index 0000000..2e0b103 --- /dev/null +++ b/src/renderer/adapters/main-more.ts @@ -0,0 +1,27 @@ +import { + OPEN_WINDOW, + OPEN_DEVTOOLS, + SHOW_MAIN_PAGE_DONATION, + SHOW_MAIN_PAGE_QRCODE, + SHOW_PRIVACY_MESSAGE +} from '#/events/constants' +import { IWindowList } from '~/universal/types/enum' +import { sendToMain } from '@/utils/dataSender' + +export const mainMoreAdapter = { + openDonationDialog () { + sendToMain(SHOW_MAIN_PAGE_DONATION) + }, + openPicBedQrcodeDialog () { + sendToMain(SHOW_MAIN_PAGE_QRCODE) + }, + openDevtools () { + sendToMain(OPEN_DEVTOOLS) + }, + openToolboxWindow () { + sendToMain(OPEN_WINDOW, IWindowList.TOOLBOX_WINDOW) + }, + openPrivacyTerms () { + sendToMain(SHOW_PRIVACY_MESSAGE) + } +} diff --git a/src/renderer/adapters/mini-page.ts b/src/renderer/adapters/mini-page.ts new file mode 100644 index 0000000..d78a058 --- /dev/null +++ b/src/renderer/adapters/mini-page.ts @@ -0,0 +1,82 @@ +import { LOG_INVALID_URL_LINES, SET_MINI_WINDOW_POS, SHOW_MINI_PAGE_MENU } from '#/events/constants' +import { + isUrl, + parseNewlineSeparatedUrls +} from '~/universal/utils/common' +import { sendToMain } from '@/utils/dataSender' +import { getFilePath } from '@/utils/common' + +function buildSendFiles (files: FileList) { + const sendFiles: IFileWithPath[] = [] + + Array.from(files).forEach((item) => { + const filePath = getFilePath(item) + if (!filePath) { + return + } + + sendFiles.push({ + name: item.name, + path: filePath + }) + }) + + return sendFiles +} + +export const miniPageAdapter = { + uploadChosenFiles (files: FileList) { + const sendFiles = buildSendFiles(files) + if (!sendFiles.length) { + return + } + + sendToMain('uploadChoosedFiles', sendFiles) + }, + uploadUrlList (urls: string[], invalidLines: string[]) { + if (invalidLines.length) { + sendToMain(LOG_INVALID_URL_LINES, invalidLines) + } + + sendToMain( + 'uploadChoosedFiles', + urls.map((url) => ({ path: url })) + ) + }, + parseDroppedUriList (uriListText: string, htmlText: string) { + const { urls, invalidLines } = parseNewlineSeparatedUrls(uriListText, { + source: 'uri-list' + }) + + if (urls.length) { + return { + urls, + invalidLines + } + } + + const urlMatch = htmlText.match(/ } + | { target: 'transformer', pluginFullName: string, draftValues: Record } + | { target: 'uploader', uploaderName: string, draftValues: Record } + +interface PluginInstallResult { + success: boolean + body: string + errMsg: string +} + +interface NpmSearchResultObject { + package: { + name: string + version: string + description: string + keywords?: string[] + maintainers?: Array<{ + username: string + }> + links?: { + homepage?: string + } + } +} + +function streamlinePluginName (fullName: string) { + return fullName.replace(/^picgo-plugin-/, '') +} + +const README_FILE_CANDIDATES = ['README.md', 'readme.md', 'Readme.md'] as const + +export const pluginsAdapter = { + getInstalledPlugins (): Promise { + return new Promise((resolve, reject) => { + const cleanup = ipc.once('pluginList', (list: IPicGoPlugin[]) => { + resolve(list) + }) + + try { + sendToMain('getPluginList') + } catch (error) { + cleanup() + reject(error) + } + }) + }, + installPlugin (fullName: string): Promise { + return invokeRPC(IRPCActionType.INSTALL_PLUGIN, fullName).then((result: IRPCResult) => ({ + success: result.success, + body: fullName, + errMsg: result.success ? '' : (result.error || '') + })) + }, + importLocalPlugin () { + return invokeRPC(IRPCActionType.IMPORT_LOCAL_PLUGIN) + }, + uninstallPlugin (fullName: string) { + return invokeRPC(IRPCActionType.UNINSTALL_PLUGIN, fullName) + }, + updatePlugin (fullName: string) { + return invokeRPC(IRPCActionType.UPDATE_PLUGIN, fullName) + }, + togglePluginEnabled (fullName: string, enabled: boolean) { + return invokeRPC( + enabled + ? IRPCActionType.ENABLE_PLUGIN + : IRPCActionType.DISABLE_PLUGIN, + fullName + ) + }, + async refreshConfigSchema (payload: IRefreshConfigSchemaArgs): Promise { + const result = await invokeRPC(IRPCActionType.REFRESH_CONFIG_SCHEMA, payload) + if (!result.success) { + throw new Error(result.error || 'Failed to refresh plugin config schema') + } + return normalizePluginConfigSchema(result.data) + }, + async saveTransformer (transformer: string) { + await saveConfig({ + 'picBed.transformer': transformer + }) + }, + async fetchPluginReadme (fullName: string, options?: { installed?: boolean }) { + // For installed plugins (including locally imported ones not on npm), + // read README from disk via the main process — jsdelivr 404s on + // unpublished plugins. Non-installed plugins (e.g. search results) fall + // back to the CDN. + if (options?.installed) { + const result = await invokeRPC( + IRPCActionType.GET_INSTALLED_PLUGIN_README, + fullName + ) + + if (!result.success) { + throw new Error(result.error || 'Failed to read local plugin readme') + } + + return result.data ?? '' + } + + let lastError: unknown = null + + for (const readmeFileName of README_FILE_CANDIDATES) { + try { + const response = await axios.get( + `https://cdn.jsdelivr.net/npm/${fullName}/${readmeFileName}`, + { + responseType: 'text' + } + ) + + return response.data || '' + } catch (error) { + lastError = error + } + } + + throw lastError instanceof Error + ? lastError + : new Error('Failed to fetch plugin readme') + }, + async fetchPluginDeprecation ( + fullName: string, + version: string + ): Promise<{ isDeprecated: boolean, message: string }> { + const encodedName = fullName.replace('/', '%2F') + const encodedVersion = encodeURIComponent(version) + const response = await axios.get<{ deprecated?: string | boolean }>( + `https://registry.npmjs.com/${encodedName}/${encodedVersion}` + ) + + const rawDeprecated = response.data?.deprecated + + if (typeof rawDeprecated === 'string') { + return { isDeprecated: true, message: rawDeprecated } + } + + if (rawDeprecated === true) { + return { isDeprecated: true, message: '' } + } + + return { isDeprecated: false, message: '' } + }, + openPluginMenu (plugin: IPicGoPlugin) { + sendToMain(SHOW_PLUGIN_PAGE_MENU, plugin) + }, + reloadApp () { + sendRPC(IRPCActionType.RELOAD_APP) + }, + async getNeedReload () { + return (await getConfig('needReload')) || false + }, + async setNeedReload (value: boolean) { + await saveConfig({ + needReload: value + }) + }, + async getPluginConfigValues (currentType: 'plugin' | 'transformer' | 'uploader', configName: string) { + if (currentType === 'plugin') { + return (await getConfig(configName)) || {} + } + + if (currentType === 'uploader') { + return (await getConfig(`picBed.${configName}`)) || {} + } + + return (await getConfig(`transformer.${configName}`)) || {} + }, + async savePluginConfig (currentType: 'plugin' | 'transformer' | 'uploader', configName: string, values: IStringKeyMap) { + if (currentType === 'plugin') { + await saveConfig({ + [configName]: values + }) + return + } + + if (currentType === 'uploader') { + await saveConfig({ + [`picBed.${configName}`]: values + }) + return + } + + await saveConfig({ + [`transformer.${configName}`]: values + }) + }, + async searchPlugins (searchText: string, installedPlugins: IPicGoPlugin[]) { + const response = await axios.get<{ objects: NpmSearchResultObject[] }>(`https://registry.npmjs.com/-/v1/search?text=${searchText}`) + const installedNames = new Set(installedPlugins.map((item) => item.fullName)) + + return response.data.objects + .filter((item) => item.package.name.includes('picgo-plugin-')) + .filter((item) => { + const description = item.package.description || '' + return !description.includes('picgo.net') && !description.includes('PicGo官方') + }) + .map((item) => ({ + name: streamlinePluginName(item.package.name), + fullName: item.package.name, + author: item.package.maintainers?.[0]?.username || '', + description: item.package.description || '', + logo: `https://cdn.jsdelivr.net/npm/${item.package.name}/logo.png`, + config: {}, + homepage: item.package.links?.homepage || '', + hasInstall: installedNames.has(item.package.name), + version: item.package.version, + gui: Boolean(item.package.keywords?.includes('picgo-gui-plugin')), + ing: false + } as IPicGoPlugin)) + }, + openPluginHomepage (url: string) { + if (!url) { + return + } + sendToMain(OPEN_URL, url) + }, + openAwesomeList () { + sendToMain(OPEN_URL, 'https://github.com/PicGo/Awesome-PicGo') + } +} diff --git a/src/renderer/adapters/providers.ts b/src/renderer/adapters/providers.ts new file mode 100644 index 0000000..731e91f --- /dev/null +++ b/src/renderer/adapters/providers.ts @@ -0,0 +1,81 @@ +import { toast } from 'sonner' +import { GET_PICBED_CONFIG } from '#/events/constants' +import { IRPCActionType } from '~/universal/types/enum' +import { invokeRPC, sendToMain } from '@/utils/dataSender' +import { ipc } from '@/utils/bridge' + +export interface ProviderSchemaResult { + config: IPicGoPluginConfig[] + name: string +} + +export const providersAdapter = { + async changeCurrentUploader (type: string, configName?: string) { + const result = await invokeRPC(IRPCActionType.CHANGE_CURRENT_UPLOADER, type, configName) + if (!result.success) { + throw new Error(result.error || 'Failed to change current uploader') + } + return result.data + }, + async getProviderConfigList (type: string) { + const result = await invokeRPC(IRPCActionType.GET_PICBED_CONFIG_LIST, type) + if (!result.success) { + throw new Error(result.error || 'Failed to load provider configs') + } + + return result.data + }, + async selectProviderConfig (type: string, configName: string) { + const result = await invokeRPC(IRPCActionType.SELECT_UPLOADER, type, configName) + if (!result.success) { + throw new Error(result.error || 'Failed to select provider config') + } + + return result.data + }, + async deleteProviderConfig (type: string, configName: string) { + const result = await invokeRPC(IRPCActionType.DELETE_PICBED_CONFIG, type, configName) + if (!result.success) { + throw new Error(result.error || 'Failed to delete provider config') + } + + return result.data + }, + async copyProviderConfig (type: string, configName: string, newConfigName: string) { + const result = await invokeRPC(IRPCActionType.COPY_UPLOADER_CONFIG, type, configName, newConfigName) + if (!result.success) { + throw new Error(result.error || 'Failed to copy provider config') + } + + return result.data + }, + async saveProviderConfig (type: string, configId: string, values: IStringKeyMap) { + const result = await invokeRPC(IRPCActionType.UPDATE_UPLOADER_CONFIG, type, configId, values) + if (!result.success) { + throw new Error(result.error || 'Failed to save provider config') + } + + if (result.data !== true) { + throw new Error('Failed to save provider config') + } + }, + getProviderSchema (type: string): Promise { + return new Promise((resolve, reject) => { + const cleanup = ipc.once(GET_PICBED_CONFIG, (config: IPicGoPluginConfig[], name: string) => { + resolve({ config, name }) + }) + + try { + sendToMain(GET_PICBED_CONFIG, type) + } catch (error) { + cleanup() + reject(error) + } + }) + } +} + +export function toastProviderError (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + toast.error(message) +} diff --git a/src/renderer/adapters/rename-page.ts b/src/renderer/adapters/rename-page.ts new file mode 100644 index 0000000..e84d4ed --- /dev/null +++ b/src/renderer/adapters/rename-page.ts @@ -0,0 +1,11 @@ +import { GET_RENAME_FILE_NAME, RENAME_FILE_NAME } from '#/events/constants' +import { sendToMain } from '@/utils/dataSender' + +export const renamePageAdapter = { + requestRenameDraft () { + sendToMain(GET_RENAME_FILE_NAME) + }, + submitRename (id: string, fileName: string) { + sendToMain(`${RENAME_FILE_NAME}${id}`, fileName) + } +} diff --git a/src/renderer/adapters/settings.ts b/src/renderer/adapters/settings.ts new file mode 100644 index 0000000..b7ae37d --- /dev/null +++ b/src/renderer/adapters/settings.ts @@ -0,0 +1,88 @@ +import { GET_PICBEDS, OPEN_URL, PICGO_OPEN_FILE, TOGGLE_SHORTKEY_MODIFIED_MODE } from '#/events/constants' +import { IRPCActionType } from '~/universal/types/enum' +import { appConfigAdapter } from './app-config' +import { getConfig, invokeRPC, saveConfig, sendRPC, sendToMain } from '@/utils/dataSender' +import { ipc } from '@/utils/bridge' + +export const settingsAdapter = { + async savePatch (patch: IStringKeyMap) { + await saveConfig(patch) + }, + async saveSingle (path: string, value: unknown) { + await saveConfig(path, value) + }, + setAutoStart (value: boolean) { + sendToMain('autoStart', value) + }, + showDockIcon (value: boolean) { + sendRPC(IRPCActionType.SHOW_DOCK_ICON, value) + }, + showMenubarIcon (value: boolean) { + sendRPC(IRPCActionType.SHOW_MENUBAR_ICON, value) + }, + openConfigFile () { + sendToMain(PICGO_OPEN_FILE, 'data.json') + }, + openLogFile () { + sendToMain(PICGO_OPEN_FILE, 'picgo.log') + }, + openExternalUrl (url: string) { + sendToMain(OPEN_URL, url) + }, + updateServer () { + sendToMain('updateServer') + }, + updateCustomLink () { + sendToMain('updateCustomLink') + }, + async checkLatestVersion (includeBeta: boolean) { + const result = await invokeRPC(IRPCActionType.GET_LATEST_VERSION, includeBeta) + if (!result.success) { + throw new Error(result.error || 'Failed to check latest version') + } + + return result.data + }, + async loadShortcuts () { + const config = await getConfig('settings.shortKey') + return config || {} + }, + toggleShortcutModifiedMode (value: boolean) { + sendToMain(TOGGLE_SHORTKEY_MODIFIED_MODE, value) + }, + toggleShortcutEnabled (item: IShortKeyConfig) { + sendToMain('bindOrUnbindShortKey', item, item.from) + }, + updateShortcut (item: IShortKeyConfig, oldKey: string) { + return new Promise((resolve, reject) => { + const cleanup = ipc.once('updateShortKeyResponse', (result: boolean) => { + resolve(result) + }) + + try { + sendToMain('updateShortKey', item, oldKey, item.from) + } catch (error) { + cleanup() + reject(error) + } + }) + }, + async loadUrlRewriteRules () { + return (await getConfig('settings.urlRewrite.rules')) || [] + }, + async saveUrlRewriteRules (rules: IStringKeyMap[]) { + await saveConfig('settings.urlRewrite.rules', rules) + }, + async updateVisiblePicBeds (visibleNames: string[]) { + const currentPicBeds = await appConfigAdapter.getPicBeds() + const nextList = currentPicBeds.map((item) => ({ + ...item, + visible: visibleNames.includes(item.name) + })) + await saveConfig({ + 'picBed.list': nextList + }) + sendToMain(GET_PICBEDS) + return nextList + } +} diff --git a/src/renderer/adapters/toolbox-page.ts b/src/renderer/adapters/toolbox-page.ts new file mode 100644 index 0000000..56c30ed --- /dev/null +++ b/src/renderer/adapters/toolbox-page.ts @@ -0,0 +1,26 @@ +import { IRPCActionType, IToolboxItemType } from '~/universal/types/enum' +import { invokeRPC, sendRPC } from '@/utils/dataSender' + +export const toolboxPageAdapter = { + runCheck () { + sendRPC(IRPCActionType.TOOLBOX_CHECK) + }, + async fixItem (type: IToolboxItemType) { + const result = await invokeRPC( + IRPCActionType.TOOLBOX_CHECK_FIX, + type + ) + + if (!result.success) { + throw new Error(result.error || 'Toolbox fix failed') + } + + return result.data + }, + openFile (path: string) { + sendRPC(IRPCActionType.OPEN_FILE, path) + }, + reloadApp () { + sendRPC(IRPCActionType.RELOAD_APP) + } +} diff --git a/src/renderer/adapters/tray-page.ts b/src/renderer/adapters/tray-page.ts new file mode 100644 index 0000000..c471aa3 --- /dev/null +++ b/src/renderer/adapters/tray-page.ts @@ -0,0 +1,50 @@ +import { OPEN_WINDOW, PASTE_TEXT } from '#/events/constants' +import { AlbumSource } from '#/types/cloudAlbum' +import { IWindowList } from '~/universal/types/enum' +import { cloudAlbumAdapter } from '@/adapters/cloud-album' +import i18n from '@/i18n' +import db from '@/utils/db' +import { showNotification } from '@/utils/notification' +import { sendToMain } from '@/utils/dataSender' +import { ipc } from '@/utils/bridge' + +type TrayPageAlbumItem = ImgInfo + +export const trayPageAdapter = { + openMainWindow () { + sendToMain(OPEN_WINDOW, IWindowList.SETTING_WINDOW) + }, + async getRecentUploadedItems (source: AlbumSource, limit = 5) { + if (source === AlbumSource.CLOUD) { + const result = await cloudAlbumAdapter.list({ limit, sort: 'newest' }) + return result.success ? result.data.items : [] + } + + return (await db.get({ orderBy: 'desc', limit })).data + }, + async copyUploadedLink (item: ImgInfo) { + await ipc.invoke(PASTE_TEXT, item) + showNotification({ + title: i18n.t('COPY_LINK_SUCCEED'), + body: item.imgUrl || '' + }) + }, + uploadClipboardFiles () { + sendToMain('uploadClipboardFiles') + }, + disableDragFile () { + const preventDefault = (event: Event) => { + event.preventDefault() + } + + window.addEventListener('dragover', preventDefault, false) + window.addEventListener('drop', preventDefault, false) + + return () => { + window.removeEventListener('dragover', preventDefault, false) + window.removeEventListener('drop', preventDefault, false) + } + } +} + +export type { TrayPageAlbumItem } diff --git a/src/renderer/adapters/window-controls.ts b/src/renderer/adapters/window-controls.ts new file mode 100644 index 0000000..697b6c6 --- /dev/null +++ b/src/renderer/adapters/window-controls.ts @@ -0,0 +1,23 @@ +import { CLOSE_WINDOW, MAXIMIZE_WINDOW, MINIMIZE_WINDOW } from '#/events/constants' +import { IRPCActionType } from '~/universal/types/enum' +import { invokeRPC, sendToMain } from '@/utils/dataSender' + +export const windowControlsAdapter = { + async getWindowState () { + const result = await invokeRPC<{ isMaximized: boolean }>(IRPCActionType.GET_WINDOW_STATE) + if (!result.success) { + throw new Error(result.error || 'Failed to get window state') + } + + return result.data + }, + closeWindow () { + sendToMain(CLOSE_WINDOW) + }, + maximizeWindow () { + sendToMain(MAXIMIZE_WINDOW) + }, + minimizeWindow () { + sendToMain(MINIMIZE_WINDOW) + } +} diff --git a/src/renderer/assets/react.svg b/src/renderer/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/src/renderer/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/components/ConfigForm.vue b/src/renderer/components/ConfigForm.vue deleted file mode 100644 index ae6e2bb..0000000 --- a/src/renderer/components/ConfigForm.vue +++ /dev/null @@ -1,153 +0,0 @@ - - - diff --git a/src/renderer/components/ToolboxHandler.vue b/src/renderer/components/ToolboxHandler.vue deleted file mode 100644 index e37173a..0000000 --- a/src/renderer/components/ToolboxHandler.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - - diff --git a/src/renderer/components/ToolboxStatusIcon.vue b/src/renderer/components/ToolboxStatusIcon.vue deleted file mode 100644 index 7e7108d..0000000 --- a/src/renderer/components/ToolboxStatusIcon.vue +++ /dev/null @@ -1,48 +0,0 @@ - - - - diff --git a/src/renderer/components/common/album-source-switcher.tsx b/src/renderer/components/common/album-source-switcher.tsx new file mode 100644 index 0000000..00556d1 --- /dev/null +++ b/src/renderer/components/common/album-source-switcher.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { albumStoreActions, useAlbumStore } from '@/store' +import { AlbumSource } from '#/types/cloudAlbum' + +export function AlbumSourceSwitcher ({ className }: { className?: string }) { + const { t } = useTranslation() + const albumSource = useAlbumStore.use.albumSource() + const isCloud = albumSource === AlbumSource.CLOUD + + return ( +
+ + +
+ ) +} diff --git a/src/renderer/components/common/app-main-card.tsx b/src/renderer/components/common/app-main-card.tsx new file mode 100644 index 0000000..a4040a1 --- /dev/null +++ b/src/renderer/components/common/app-main-card.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import { Slot } from "@/lib/slot" + +import { cn } from "@/lib/utils" + +interface AppMainCardProps extends React.ComponentProps<"div"> { + asChild?: boolean +} + +export function AppMainCard({ + asChild = false, + className, + ...props +}: AppMainCardProps) { + const Comp = asChild ? Slot : "div" + + return ( + + ) +} diff --git a/src/renderer/components/common/app-reload-bar.tsx b/src/renderer/components/common/app-reload-bar.tsx new file mode 100644 index 0000000..b53be93 --- /dev/null +++ b/src/renderer/components/common/app-reload-bar.tsx @@ -0,0 +1,41 @@ +import { RefreshCwIcon } from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import { useTranslation } from "react-i18next" + +import { pluginsAdapter } from "@/adapters/plugins" +import { useAppStore } from "@/store" +import { Button } from "@/components/ui/button" + +export function AppReloadBar() { + const { t } = useTranslation() + const needReload = useAppStore((state) => state.appConfig?.needReload ?? false) + + return ( + + {needReload ? ( + +
+ +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/components/common/cloud-empty-state.tsx b/src/renderer/components/common/cloud-empty-state.tsx new file mode 100644 index 0000000..61988ba --- /dev/null +++ b/src/renderer/components/common/cloud-empty-state.tsx @@ -0,0 +1,159 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { useNavigate } from "@tanstack/react-router" +import { CrownIcon, LoaderCircleIcon, LogInIcon, UploadCloudIcon } from "lucide-react" +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { openURL } from "@/utils/dataSender" +import { UserPlanLevel, type IPicGoCloudUserInfo } from "#/types/cloud" + +const PICGO_CLOUD_PRICING_URL = 'https://cloud.picgo.app/pricing' + +type CloudEmptyStateProps = { + userInfo: IPicGoCloudUserInfo | null | undefined + cloudTotal: number + cloudLoading: boolean + onStartImport: () => Promise | void +} + +function isPaidUser (userInfo: IPicGoCloudUserInfo | null | undefined): boolean { + return (userInfo?.plan ?? UserPlanLevel.Free) > UserPlanLevel.Free +} + +function ImportButton ({ onStartImport, needsConfirm }: { onStartImport: () => Promise | void, needsConfirm: boolean }) { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + + const handleImport = async () => { + setLoading(true) + try { + await onStartImport() + } finally { + setLoading(false) + setOpen(false) + } + } + + if (!needsConfirm) { + return ( + + ) + } + + return ( + + + + + + + {t("ALBUM_CLOUD_IMPORT_CONFIRM_TITLE")} + + {t("ALBUM_CLOUD_IMPORT_CONFIRM_DESC")} + + + + {t("CANCEL")} + + + + + ) +} + +export function CloudEmptyState ({ + userInfo, + cloudTotal, + cloudLoading, + onStartImport, +}: CloudEmptyStateProps) { + const { t } = useTranslation() + const navigate = useNavigate() + + const goToCloudTab = () => { + navigate({ to: '/main/cloud' }) + } + + // Not logged in + if (userInfo === null || userInfo === undefined) { + return ( +
+
+ +
+
+

{t("ALBUM_CLOUD_LOGIN_REQUIRED_TITLE")}

+

{t("ALBUM_CLOUD_LOGIN_REQUIRED_DESC")}

+
+ +
+ ) + } + + // Free user + if (!isPaidUser(userInfo)) { + return ( +
+
+ +
+
+

{t("ALBUM_CLOUD_UPGRADE_TITLE")}

+

{t("ALBUM_CLOUD_UPGRADE_DESC")}

+
+ +
+ ) + } + + // Paid user, empty album + if (cloudTotal === 0 && !cloudLoading) { + return ( +
+
+ +
+
+

{t("ALBUM_CLOUD_IMPORT_GUIDE_TITLE")}

+

{t("ALBUM_CLOUD_IMPORT_GUIDE_DESC")}

+
+ +
+ ) + } + + return null +} diff --git a/src/renderer/components/common/cloud-feature-highlights.tsx b/src/renderer/components/common/cloud-feature-highlights.tsx new file mode 100644 index 0000000..38cd403 --- /dev/null +++ b/src/renderer/components/common/cloud-feature-highlights.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from "react-i18next" +import { CloudIcon, RefreshCwIcon, UploadCloudIcon } from "lucide-react" + +const FEATURES = [ + { icon: CloudIcon, key: 'ALBUM_CLOUD_FEATURE_SYNC' }, + { icon: RefreshCwIcon, key: 'ALBUM_CLOUD_FEATURE_AUTO_IMPORT' }, + { icon: UploadCloudIcon, key: 'ALBUM_CLOUD_FEATURE_SECURE' }, +] as const + +export function CloudFeatureHighlights () { + const { t } = useTranslation() + + return ( +
+
+ {t("ALBUM_CLOUD_FEATURES_TITLE")} +
+
+ {FEATURES.map(({ icon: Icon, key }) => ( +
+
+ +
+ + {t(key)} + +
+ ))} +
+
+ ) +} diff --git a/src/renderer/components/common/cloud-image.tsx b/src/renderer/components/common/cloud-image.tsx new file mode 100644 index 0000000..6532e9d --- /dev/null +++ b/src/renderer/components/common/cloud-image.tsx @@ -0,0 +1,34 @@ +import { type ImgHTMLAttributes } from 'react' +import { useCloudImageSrc } from '@/hooks/use-cloud-image-src' + +type ImgProps = ImgHTMLAttributes + +interface CloudImageProps extends Omit { + /** + * 图片对应的图床 type(来自 ImgInfo.type)。当 type === 'picgo-cloud' 时启用 + * lifecycle 占位保护——其他图床(包括用户自定义域名走 PicGo Cloud 的情况下, + * type 仍是 'picgo-cloud')不影响。本地图床 / 第三方图床的图片透传 ``。 + */ + type?: string +} + +/** + * 包装 ``,在 PicGo Cloud lifecycle 处于 frozen / pending_cleanup + * 时把图片 src 替换为本地占位 SVG,避免远端 503 / 破图。 + * - 仍渲染原生 ``,调用方(卡片 / preview / inspect 等)样式无需改动; + * - onError 同样回落到占位 SVG,覆盖临时网络异常或自定义域名失效场景; + * - 续费入口由 lifecycle banner 提供,本组件不再渲染 CTA。 + */ +export function CloudImage ({ type, ...imgProps }: CloudImageProps) { + const { src, onError } = useCloudImageSrc(type, imgProps.src) + return ( + { + onError(event) + imgProps.onError?.(event) + }} + /> + ) +} diff --git a/src/renderer/components/common/cloud-refresh-button.tsx b/src/renderer/components/common/cloud-refresh-button.tsx new file mode 100644 index 0000000..80b0a1c --- /dev/null +++ b/src/renderer/components/common/cloud-refresh-button.tsx @@ -0,0 +1,21 @@ +import { useTranslation } from "react-i18next" +import { RefreshCwIcon } from "lucide-react" +import { TooltipIconButton } from "@/components/common/tooltip-icon-button" + +type CloudRefreshButtonProps = { + onRefresh: () => Promise | void + className?: string +} + +export function CloudRefreshButton ({ onRefresh, className }: CloudRefreshButtonProps) { + const { t } = useTranslation() + + return ( + } + onClick={async () => { await onRefresh() }} + className={className} + /> + ) +} diff --git a/src/renderer/components/common/component-example.tsx b/src/renderer/components/common/component-example.tsx new file mode 100644 index 0000000..e74aa74 --- /dev/null +++ b/src/renderer/components/common/component-example.tsx @@ -0,0 +1,493 @@ +import * as React from "react" + +import { + Example, + ExampleWrapper, +} from "@/components/common/example" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { PlusIcon, BluetoothIcon, MoreVerticalIcon, FileIcon, FolderIcon, FolderOpenIcon, FileCodeIcon, MoreHorizontalIcon, FolderSearchIcon, SaveIcon, DownloadIcon, EyeIcon, LayoutIcon, PaletteIcon, SunIcon, MoonIcon, MonitorIcon, UserIcon, CreditCardIcon, SettingsIcon, KeyboardIcon, LanguagesIcon, BellIcon, MailIcon, ShieldIcon, HelpCircleIcon, FileTextIcon, LogOutIcon } from "lucide-react" + +export function ComponentExample() { + return ( + + + + + ) +} + +function CardExample() { + return ( + + +
+ Photo by mymind on Unsplash + + Observability Plus is replacing Monitoring + + Switch to the improved way to explore your data, with natural + language. Monitoring will no longer be available on the Pro plan in + November, 2025 + + + + + + + + + + + + + Allow accessory to connect? + + Do you want to allow the USB accessory to connect to this + device? + + + + Don't allow + Allow + + + + + Warning + + + + + ) +} + +const frameworks = [ + "Next.js", + "SvelteKit", + "Nuxt.js", + "Remix", + "Astro", +] as const + +function FormExample() { + const [notifications, setNotifications] = React.useState({ + email: true, + sms: false, + push: true, + }) + const [theme, setTheme] = React.useState("light") + + return ( + + + + User Information + Please fill in your details below + + + + + + + + File + + + New File + ⌘N + + + + New Folder + ⇧⌘N + + + + + Open Recent + + + + + Recent Projects + + + Project Alpha + + + + Project Beta + + + + + More Projects + + + + + + Project Gamma + + + + Project Delta + + + + + + + + + + Browse... + + + + + + + + + Save + ⌘S + + + + Export + ⇧⌘E + + + + + View + + setNotifications({ + ...notifications, + email: checked === true, + }) + } + > + + Show Sidebar + + + setNotifications({ + ...notifications, + sms: checked === true, + }) + } + > + + Show Status Bar + + + + + Theme + + + + + Appearance + + + + Light + + + + Dark + + + + System + + + + + + + + + + Account + + + Profile + ⇧⌘P + + + + Billing + + + + + Settings + + + + + Preferences + + + Keyboard Shortcuts + + + + Language + + + + + Notifications + + + + + + Notification Types + + + setNotifications({ + ...notifications, + push: checked === true, + }) + } + > + + Push Notifications + + + setNotifications({ + ...notifications, + email: checked === true, + }) + } + > + + Email Notifications + + + + + + + + + + + Privacy & Security + + + + + + + + + + + Help & Support + + + + Documentation + + + + + + + Sign Out + ⇧⌘Q + + + + + + + +
+ +
+ + Name + + + + Role + + +
+ + + Framework + + + + + No frameworks found. + + {(item) => ( + + {item} + + )} + + + + + + Comments +