tr]:last:border-b-0',
className,
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
)
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
)
}
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
}
================================================
FILE: src/components/ui/tabs.tsx
================================================
'use client'
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
import { Tabs as TabsPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Tabs({
className,
orientation = 'horizontal',
...props
}: React.ComponentProps) {
return (
)
}
const tabsListVariants = cva(
'rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col',
{
variants: {
variant: {
default: 'bg-muted',
line: 'gap-1 bg-transparent',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function TabsList({
className,
variant = 'default',
...props
}: React.ComponentProps
& VariantProps) {
return (
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps) {
return (
)
}
function TabsContent({
className,
...props
}: React.ComponentProps) {
return (
)
}
export { Tabs, TabsContent, TabsList, tabsListVariants, TabsTrigger }
================================================
FILE: src/components/ui/textarea.tsx
================================================
import * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
)
}
export { Textarea }
================================================
FILE: src/components/ui/tooltip.tsx
================================================
import { Tooltip as TooltipPrimitive } from 'radix-ui'
import * as React from 'react'
import { cn } from '@/lib/utils'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps) {
return (
)
}
function Tooltip({
...props
}: React.ComponentProps) {
return
}
function TooltipTrigger({
...props
}: React.ComponentProps) {
return
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps) {
return (
{children}
)
}
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
================================================
FILE: src/components/user-avatar.tsx
================================================
import { User } from 'lucide-react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { cn } from '@/lib/utils'
interface UserAvatarProps {
src?: string
alt?: string
className?: string
size?: 'default' | 'sm' | 'lg'
}
export default function UserAvatar({
src,
alt,
className,
size = 'default',
}: UserAvatarProps) {
return (
)
}
================================================
FILE: src/components/widget-sync-item.tsx
================================================
import type { Widget } from '@widget-js/core'
import { WidgetApi } from '@widget-js/core'
import { Clock } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemHeader,
ItemTitle,
} from '@/components/ui/item'
interface WidgetSyncItemProps {
widget: Widget
}
export default function WidgetSyncItem({ widget }: WidgetSyncItemProps) {
const { i18n } = useTranslation()
const [latestSyncAt, setLatestSyncAt] = useState('')
const getLocalizedText = (text: any) => {
if (typeof text === 'string') { return text }
if (!text) { return '' }
return text[i18n.language] || text['zh-CN'] || Object.values(text)[0] || ''
}
useEffect(() => {
WidgetApi.getSyncInfo(widget.name).then((it) => {
if (it && it.latestSyncTime) {
setLatestSyncAt(`${new Date(it.latestSyncTime).toLocaleString()}`)
}
else {
setLatestSyncAt('暂无同步')
}
})
}, [widget.name])
return (
-
{getLocalizedText(widget.title) || widget.name}
{widget.name}
{latestSyncAt}
)
}
================================================
FILE: src/hooks/use-app-broadcast.ts
================================================
import type { BroadcastEvent, BroadcastEventType } from '@widget-js/core'
import { BroadcastApi, Channel } from '@widget-js/core'
import { useEffect, useRef } from 'react'
import { useIpcListener } from './use-ipc-listener'
export function useAppBroadcast(
events: BroadcastEventType[],
callback: (event: BroadcastEvent) => void,
) {
const callbackRef = useRef(callback)
useEffect(() => {
callbackRef.current = callback
}, [callback])
useEffect(() => {
BroadcastApi.register(...events)
return () => {
BroadcastApi.unregister(...events)
}
}, [JSON.stringify(events)])
useIpcListener(Channel.BROADCAST, (...args: any[]) => {
const event = args[0] as BroadcastEvent
if (events.includes(event.event)) {
callbackRef.current(event)
}
})
}
================================================
FILE: src/hooks/use-app-language.ts
================================================
import type { BroadcastEvent, LanguageCode } from '@widget-js/core'
import { AppApi, AppApiEvent } from '@widget-js/core'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useAppBroadcast } from './use-app-broadcast'
export interface UseAppLanguageOption {
onLoad?: (code: string) => void
onChange?: (code: string) => void
}
export function useAppLanguage(options?: UseAppLanguageOption) {
const [languageCode, setLanguageCode] = useState(navigator.language)
const loadedRef = useRef(false)
useEffect(() => {
AppApi.getLanguageCode().then((result) => {
setLanguageCode(result)
loadedRef.current = true
options?.onLoad?.(result)
})
}, []) // Empty dependency array means this runs once on mount
const handleBroadcast = useCallback((event: BroadcastEvent) => {
if (event.event === AppApiEvent.LANGUAGE_CHANGED) {
if (languageCode !== event.payload && typeof event.payload === 'string') {
setLanguageCode(event.payload)
options?.onChange?.(event.payload)
}
}
}, [languageCode, options])
useAppBroadcast([AppApiEvent.LANGUAGE_CHANGED], handleBroadcast)
const updateLanguageCode = async (newCode: string) => {
setLanguageCode(newCode)
if (loadedRef.current) {
await AppApi.setLanguageCode(newCode as LanguageCode)
}
}
return [languageCode, updateLanguageCode] as const
}
================================================
FILE: src/hooks/use-app-runtime-info.ts
================================================
import type { AppRuntimeInfo } from '@widget-js/core'
import { AppApi } from '@widget-js/core'
import { useEffect, useMemo, useState } from 'react'
export type SimpleAppRuntimeInfo = Omit
export function useAppRuntimeInfo() {
const [info, setInfo] = useState()
useEffect(() => {
AppApi.getRuntimeInfo().then((data) => {
const res = Object.keys(data).sort().reduce((obj: any, key: any) => {
obj[key] = (data as any)[key]
return obj
}, {})
setInfo(res as AppRuntimeInfo)
})
}, [])
const simpleInfo = useMemo(() => {
if (info) {
const { ...simple } = info
return simple
}
return undefined
}, [info])
return { info, simpleInfo }
}
================================================
FILE: src/hooks/use-cell-size-config.ts
================================================
import { AppApi } from '@widget-js/core'
import { useEffect, useRef, useState } from 'react'
export function useCellSizeConfig() {
const [gridSize, setGridSize] = useState(80)
const loadedRef = useRef(false)
useEffect(() => {
AppApi.getGridCellSize().then((size) => {
setGridSize(size)
loadedRef.current = true
})
}, [])
const updateGridSize = async (newSize: number) => {
setGridSize(newSize)
if (loadedRef.current) {
await AppApi.setGridCellSize(newSize)
}
}
return [gridSize, updateGridSize] as const
}
================================================
FILE: src/hooks/use-debounce.ts
================================================
import { useEffect, useState } from 'react'
export function useDebounce(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}
================================================
FILE: src/hooks/use-debug-config.ts
================================================
import { AppApi } from '@widget-js/core'
import { useEffect, useRef, useState } from 'react'
export function useDebugConfig(onLoad?: (debug: boolean) => void) {
const [debugMode, setDebugMode] = useState(false)
const loadedRef = useRef(false)
useEffect(() => {
AppApi.getDevMode().then((mode) => {
setDebugMode(mode)
loadedRef.current = true
onLoad?.(mode)
})
}, [onLoad])
const updateDebugMode = async (newMode: boolean) => {
setDebugMode(newMode)
if (loadedRef.current) {
await AppApi.setDevMode(newMode)
}
}
return [debugMode, updateDebugMode] as const
}
================================================
FILE: src/hooks/use-ipc-listener.ts
================================================
import type { Channel } from '@widget-js/core'
import { ElectronApi } from '@widget-js/core'
import { useEffect, useRef } from 'react'
export function useIpcListener(channel: Channel | string, callback: (...args: any[]) => void) {
const callbackRef = useRef(callback)
useEffect(() => {
callbackRef.current = callback
}, [callback])
useEffect(() => {
const handler = (...args: any[]) => {
callbackRef.current(...args)
}
ElectronApi.addIpcListener(channel, handler)
return () => {
ElectronApi.removeIpcListener(channel)
}
}, [channel])
}
================================================
FILE: src/hooks/use-launch-at-startup-config.ts
================================================
import { ApiConstants, AppApi } from '@widget-js/core'
import { useEffect, useRef, useState } from 'react'
export function useLaunchAtStartupConfig() {
const [launchAtStartup, setLaunchAtStartup] = useState(true)
const loadedRef = useRef(false)
useEffect(() => {
AppApi.getConfig(ApiConstants.CONFIG_LAUNCH_AT_STARTUP, true).then((startup) => {
setLaunchAtStartup(startup as boolean)
loadedRef.current = true
})
}, [])
const updateLaunchAtStartup = async (newValue: boolean) => {
setLaunchAtStartup(newValue)
if (loadedRef.current) {
await AppApi.setConfig(ApiConstants.CONFIG_LAUNCH_AT_STARTUP, newValue)
}
}
return [launchAtStartup, updateLaunchAtStartup] as const
}
================================================
FILE: src/hooks/use-mobile.ts
================================================
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}
================================================
FILE: src/hooks/use-supabase-channel.ts
================================================
import consola from 'consola'
import { useEffect, useMemo, useRef } from 'react'
import { supabase } from '@/api/supabase'
export function useSupabaseChannel(channelName: string, onCallback: (payload: any) => void) {
// Use a ref to store the callback to avoid re-subscribing when the callback function changes
const onCallbackRef = useRef(onCallback)
// Update the ref whenever the callback changes
useEffect(() => {
onCallbackRef.current = onCallback
}, [onCallback])
// Create the channel instance using useMemo to keep it stable across renders
// unless channelName changes.
// If channelName is empty, we return null to skip subscription.
const channel = useMemo(() => {
if (!channelName) { return null }
return supabase.channel(channelName)
}, [channelName])
useEffect(() => {
if (!channel) { return }
// Subscribe to the broadcast event
channel
.on('broadcast', { event: 'wechat-login' }, (payload) => {
if (onCallbackRef.current) {
onCallbackRef.current(payload)
}
})
.subscribe((status) => {
consola.log('Supabase channel subscription status:', status)
})
// Cleanup function to unsubscribe when component unmounts or channel changes
return () => {
channel.unsubscribe()
}
}, [channel])
return {
channel,
// Provide a manual unsubscribe method if needed, similar to the 'teardown' in the Vue example
unsubscribe: () => channel?.unsubscribe(),
}
}
================================================
FILE: src/hooks/use-user.ts
================================================
import type { User } from '@supabase/supabase-js'
import { UserApi } from '@widget-js/core'
import consola from 'consola'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { supabase } from '@/api/supabase'
// Global state to mimic Vue's global ref behavior
let globalUser: User | null = null
const listeners = new Set<(user: User | null) => void>()
function notifyListeners() {
listeners.forEach(listener => listener(globalUser))
}
function setGlobalUser(user: User | null) {
globalUser = user
notifyListeners()
}
// Initialize the auth listener once (outside the hook)
supabase.auth.onAuthStateChange((event, session) => {
consola.info('onAuthStateChange', event, session)
if (event === 'SIGNED_OUT') {
setGlobalUser(null)
UserApi.logout()
}
else if (event === 'USER_UPDATED') {
setGlobalUser(session?.user ?? null)
if (session?.user) {
UserApi.updateUser(session.user)
}
}
else if (event === 'SIGNED_IN') {
setGlobalUser(session?.user ?? null)
supabase.auth.startAutoRefresh()
if (session) {
UserApi.login(session)
}
}
else if (event === 'TOKEN_REFRESHED') {
if (session) {
UserApi.updateSession(session)
}
}
})
export function useUser(onload?: (user?: User) => void) {
const [user, setUser] = useState(globalUser)
const [loading, setLoading] = useState(false)
// Use a ref for the callback to avoid re-triggering effects if the callback is unstable
const onloadRef = useRef(onload)
useEffect(() => {
onloadRef.current = onload
}, [onload])
// Sync local state with global state
useEffect(() => {
const listener = (newUser: User | null) => {
setUser(newUser)
}
listeners.add(listener)
// Check if global state changed while we were setting up
if (globalUser !== user) {
setUser(globalUser)
}
return () => {
listeners.delete(listener)
}
}, [user])
const refreshUser = useCallback(() => {
setLoading(true)
supabase.auth.getUser().then(({ data }) => {
setGlobalUser(data.user)
onloadRef.current?.(data.user || undefined)
}).finally(() => {
setLoading(false)
})
}, [])
// Initial refresh on mount
useEffect(() => {
refreshUser()
}, [refreshUser])
const nickname = useMemo(() => {
if (user) {
if (user.user_metadata?.nickname) {
return user.user_metadata.nickname
}
if (user.email) {
return user.email.split('@')[0]
}
return 'User'
}
return '未登录'
}, [user])
const userId = useMemo(() => {
return user?.id || ''
}, [user])
const avatar = useMemo(() => {
return user?.user_metadata?.avatar || ''
}, [user])
return { user, refreshUser, loading, nickname, avatar, userId }
}
================================================
FILE: src/hooks/use-widget-package.ts
================================================
import type { RemotePackageUrlInfo } from '@widget-js/core'
import { NotificationApi, WidgetPackageApi } from '@widget-js/core'
import consola from 'consola'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import semver from 'semver'
const upgradablePackages = new Set()
const upgradingPackages = new Set()
const checkingPackages = new Set()
const listeners = new Map void>>()
function subscribe(packageName: string, callback: () => void) {
if (!listeners.has(packageName)) {
listeners.set(packageName, new Set())
}
listeners.get(packageName)!.add(callback)
}
function unsubscribe(packageName: string, callback: () => void) {
const pkgListeners = listeners.get(packageName)
if (pkgListeners) {
pkgListeners.delete(callback)
if (pkgListeners.size === 0) {
listeners.delete(packageName)
}
}
}
function notify(packageName: string) {
listeners.get(packageName)?.forEach(cb => cb())
}
export function useWidgetPackage(packageName: string, remoteVersion: string, remoteUrlInfo?: RemotePackageUrlInfo) {
const { t } = useTranslation()
const [upgradable, setUpgradable] = useState(upgradablePackages.has(packageName))
const [upgrading, setUpgrading] = useState(upgradingPackages.has(packageName))
// We don't necessarily need to expose 'checking' as a return value based on the Vue type,
// but we can track it internally or use the global set to prevent double checking.
useEffect(() => {
const updateState = () => {
setUpgradable(upgradablePackages.has(packageName))
setUpgrading(upgradingPackages.has(packageName))
}
// Initial sync in case it changed before effect ran
updateState()
subscribe(packageName, updateState)
return () => {
unsubscribe(packageName, updateState)
}
}, [packageName])
const checkUpgrade = useCallback(async () => {
if (checkingPackages.has(packageName)) {
return false
}
checkingPackages.add(packageName)
// We don't trigger re-render for checking status as it's not in the return interface
try {
const widgetPackage = await WidgetPackageApi.getPackage(packageName)
if (widgetPackage) {
if (widgetPackage.name === 'widget.js.fun') {
consola.log(remoteVersion)
}
const isUpgradable = semver.gt(remoteVersion, widgetPackage.version ?? '1.0.0')
if (isUpgradable) {
if (!upgradablePackages.has(packageName)) {
upgradablePackages.add(packageName)
notify(packageName)
}
}
else {
if (upgradablePackages.has(packageName)) {
upgradablePackages.delete(packageName)
notify(packageName)
}
}
return isUpgradable
}
if (upgradablePackages.has(packageName)) {
upgradablePackages.delete(packageName)
notify(packageName)
}
}
catch (e) {
consola.error(e)
}
finally {
checkingPackages.delete(packageName)
}
return false
}, [packageName, remoteVersion])
const upgradePackage = useCallback(async () => {
upgradingPackages.add(packageName)
notify(packageName)
try {
if (!remoteUrlInfo) {
NotificationApi.error(t('update.packageNotConfigured'))
return
}
await WidgetPackageApi.upgrade(packageName, remoteUrlInfo)
upgradingPackages.delete(packageName)
upgradablePackages.delete(packageName)
NotificationApi.success(t('update.packageSuccess'))
notify(packageName)
}
catch (e) {
consola.error(e)
// Remove from upgrading if failed
upgradingPackages.delete(packageName)
notify(packageName)
}
}, [packageName, remoteUrlInfo])
return {
upgradable,
upgrading,
checkUpgrade,
upgradePackage,
}
}
================================================
FILE: src/i18n/config.ts
================================================
import i18n from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import { initReactI18next } from 'react-i18next'
import enTranslation from './locales/en/translation.json'
import zhTranslation from './locales/zh/translation.json'
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'zh',
debug: true,
resources: {
en: {
translation: enTranslation,
},
zh: {
translation: zhTranslation,
},
},
interpolation: {
escapeValue: false, // not needed for react as it escapes by default
},
})
export default i18n
================================================
FILE: src/i18n/locales/en/translation.json
================================================
{
"network": {
"failed": "Load failed: {{msg}}",
"offline": "Network offline"
},
"loading": "Loading...",
"manager": {
"title": "Title",
"name": "Name",
"remove": "Remove",
"removeDesc": "Are you sure to remove {{name}}?",
"confirmRemove": "Are you sure to remove?"
},
"settings": {
"title": "Settings",
"widgetPackage": {
"managerTitle": "Installed Management",
"title": "Title:",
"name": "Name:",
"installPath": "Install Path:",
"uninstall": "Uninstall",
"uninstallConfirm": "Are you sure you want to uninstall?",
"uninstallDesc": "Uninstalling {{name}} cannot be undone.",
"cancel": "Cancel",
"confirm": "Confirm"
}
},
"sidebar": {
"addWidget": "Add Widget",
"packageManagement": "Widget Package",
"generalSettings": "General Settings",
"globalTheme": "Global Theme",
"proxySettings": "Proxy",
"ai": "AI",
"widgetManagement": "Widget Management",
"settings": "Settings",
"loading": "Loading...",
"widgetHub": "Widget Hub",
"dev": "Widget Dev"
},
"dev": {
"emptyTitle": "Develop Your Own Desktop Widget",
"viewDocs": "View Documentation"
},
"general": {
"title": "General Settings",
"appSettings": "App Settings",
"launchAtStartup": "Launch at Startup",
"language": "Language",
"selectLanguage": "Select Language",
"developerMode": "Developer Mode",
"currentVersion": "Current Version",
"desktopSettings": "Desktop Settings",
"gridSize": "Grid Size",
"socialAccounts": "Social Accounts"
},
"proxy": {
"title": "Proxy Settings",
"warning": "Configuring a proxy server may cause network connection interruptions, please proceed with caution.",
"protocol": "Protocol",
"server": "Server",
"port": "Port",
"clearProxy": "Clear Proxy"
},
"theme": {
"title": "Global Theme",
"presets": "Presets",
"createPreset": {
"button": "Create preset",
"title": "Create theme preset",
"description": "Save the current theme as a reusable preset.",
"namePlaceholder": "Enter a theme name",
"cancel": "Cancel",
"confirm": "Create",
"emptyName": "Please enter a theme name",
"duplicateName": "A preset with the same name already exists",
"success": "Theme preset created"
},
"customization": "Customization",
"fontSize": "Font Size",
"borderRadius": "Border Radius",
"fontFamily": "Font Family",
"systemDefault": "System Default",
"primaryColor": "Primary Color",
"textColor": "Text Color",
"backgroundColor": "Background Color",
"borderColor": "Border Color",
"shadowColor": "Shadow Color",
"dividerColor": "Divider Color",
"translucent": "Translucent",
"dark": "Dark",
"light": "Light",
"searchFont": "Search font...",
"fontNotFound": "No font found",
"colors": {
"title": "Colors",
"base": "Base",
"primary": "Primary",
"secondary": "Secondary",
"muted": "Muted",
"accent": "Accent",
"destructive": "Destructive",
"cardPopover": "Card & Popover",
"bordersInputs": "Borders & Inputs",
"background": "Background",
"foreground": "Foreground",
"card": "Card",
"cardForeground": "Card Foreground",
"popover": "Popover",
"popoverForeground": "Popover Foreground",
"primaryForeground": "Primary Foreground",
"secondaryForeground": "Secondary Foreground",
"mutedForeground": "Muted Foreground",
"accentForeground": "Accent Foreground",
"destructiveForeground": "Destructive Foreground",
"border": "Border",
"input": "Input",
"ring": "Ring",
"shadow": "Shadow",
"innerShadow": "Inner Shadow"
},
"typography": {
"title": "Typography"
},
"radius": {
"title": "Radius",
"sm": "Small Radius (sm)",
"md": "Medium Radius (md)",
"lg": "Large Radius (lg/Widget Usage)",
"full": "Full Radius"
},
"shadow": {
"title": "Shadow",
"sm": "Small Shadow (sm)",
"md": "Medium Shadow (md)",
"lg": "Large Shadow (lg)"
},
"preview": {
"title": "Theme Preview",
"description": "This is how your widgets and app components will look with the current theme settings.",
"cardTitle": "Example Component",
"cardDesc": "A descriptive subtitle for the card.",
"email": "Email Address",
"emailPlaceholder": "name@example.com",
"enableNotifications": "Enable notifications",
"cancel": "Cancel",
"save": "Save Changes",
"buttons": "Buttons",
"primary": "Primary",
"secondary": "Secondary",
"destructive": "Destructive",
"outline": "Outline",
"ghost": "Ghost",
"inputRing": "Input & Focus Ring",
"inputPlaceholder": "Focus me to see the ring color...",
"slider": "Slider"
}
},
"dashboard": {
"home": "Home",
"dashboard": "Dashboard"
},
"appInfo": {
"title": "App Info",
"loading": "Loading..."
},
"user": {
"account": "Account",
"logout": "Log out"
},
"tray": {
"appVersion": "Version",
"systemVersion": "System",
"runningWidgets": "Running Widgets",
"addWidget": "Add Widget",
"settings": {
"title": "Settings"
},
"checkUpdates": "Check Updates",
"shareApp": "Share",
"restartWidgets": "Restart",
"exit": "Exit",
"suggestions": "Suggestions",
"downloadLinkCopied": "Download link copied",
"infoCopied": "Info copied",
"restartWidgetsConfirm": "Are you sure to restart all widgets?",
"yes": "Yes",
"no": "No"
},
"notification": {
"enableDevMode": "Developer mode enabled"
},
"drop": {
"title": "Install Local Widget Package",
"desc": "Drag and drop .zip widget package here to install",
"dismiss": "Got it"
},
"update": {
"upgradeToNew": "Please upgrade app to use this widget",
"success": "Update successful",
"packageNotConfigured": "Widget package info not configured",
"packageSuccess": "Widget package upgraded successfully",
"failed": "Update failed",
"windowTitle": "Check for Updates",
"alreadyLatestVersion": "Already latest version",
"newVersionDetect": "New version detected",
"ignore": "Ignore",
"storePage": "Store Page",
"update": "Update",
"check": "Check for Updates"
},
"search": {
"title": "Add Widget",
"placeholder": "Search widgets",
"installedManagement": "Installed",
"developerTip": "Developer mode enabled",
"devDoc": "Developer Docs",
"enable": "Enable",
"disable": "Disable",
"desktop": "Desktop",
"overlap": "Overlap",
"tray": "Tray",
"upgrade": "Upgrade",
"installOffline": "Install Offline Widget",
"desktopShortcut": "Shortcut"
},
"tags": {
"all": "All",
"ai": "AI",
"tools": "Tools",
"productivity": "Productivity",
"news": "News",
"weather": "Weather",
"fun": "Fun",
"calendar": "Calendar",
"time": "Time",
"finance": "Finance",
"photo": "Photo",
"installed": "Installed",
"debug": "Debug"
},
"feature": {
"wishlist": "Wishlist",
"wishlistDesc": "Vote for features you want to see in the next version!"
},
"install": {
"success": "Install successful",
"failed": "Install failed",
"noUrl": "Download URL not found",
"action": "Install"
},
"error": {
"oops": "Oops!",
"unexpected": "Sorry, an unexpected error has occurred.",
"unknown": "Unknown error",
"backToHome": "Back to Home"
},
"aiPage": {
"loadPackagesFailed": "Failed to load packages",
"loadHistoryFailed": "Failed to load history",
"tokenPackages": "Token Packages",
"purchasePackage": "Purchase Package",
"expirationDate": "Expiration Date:",
"lifetime": "Lifetime",
"used": "Used:",
"total": "Total:",
"noPackages": "No packages available",
"usageHistory": "Usage History",
"time": "Time",
"model": "Model",
"type": "Type",
"tokens": "Tokens",
"remark": "Remark",
"loading": "Loading...",
"noHistory": "No history records"
},
"sizePage": {
"title": "Widget Size Settings",
"width": "Width",
"height": "Height",
"widthDesc": "Set the width of the widget ({{value}} px), range {{min}} - {{max}} px.",
"heightDesc": "Set the height of the widget ({{value}} px), range {{min}} - {{max}} px."
}
}
================================================
FILE: src/i18n/locales/zh/translation.json
================================================
{
"network": {
"failed": "加载失败: {{msg}}",
"offline": "网络已断开"
},
"loading": "加载中...",
"manager": {
"title": "标题",
"name": "名称",
"remove": "移除",
"removeDesc": "确定要移除 {{name}} 吗?",
"confirmRemove": "确定要删除吗?"
},
"settings": {
"title": "设置",
"widgetPackage": {
"managerTitle": "已安装管理",
"title": "标题:",
"name": "名称:",
"installPath": "安装路径:",
"uninstall": "卸载",
"uninstallConfirm": "确定要卸载吗?",
"uninstallDesc": "卸载 {{name}} 将无法恢复。",
"cancel": "取消",
"confirm": "确定"
}
},
"sidebar": {
"addWidget": "添加组件",
"packageManagement": "组件包管理",
"generalSettings": "常用设置",
"globalTheme": "全局主题",
"proxySettings": "代理设置",
"ai": "AI",
"widgetManagement": "组件管理",
"settings": "设置",
"loading": "加载中...",
"widgetHub": "桌面组件",
"dev": "开发组件"
},
"dev": {
"emptyTitle": "开发属于自己的桌面组件",
"viewDocs": "查看文档"
},
"general": {
"title": "常用设置",
"appSettings": "应用设置",
"launchAtStartup": "开机自启动",
"language": "语言",
"selectLanguage": "选择语言",
"developerMode": "开发者模式",
"currentVersion": "当前版本",
"desktopSettings": "桌面设置",
"gridSize": "网格大小",
"socialAccounts": "社交账号"
},
"proxy": {
"title": "代理设置",
"warning": "配置代理服务器可能会导致网络连接中断,请谨慎操作。",
"protocol": "协议",
"server": "服务器",
"port": "端口",
"clearProxy": "清除代理"
},
"theme": {
"title": "全局主题",
"presets": "预设",
"createPreset": {
"button": "创建预设",
"title": "创建主题预设",
"description": "为当前主题保存一个可重复使用的预设。",
"namePlaceholder": "请输入主题名称",
"cancel": "取消",
"confirm": "创建",
"emptyName": "请输入主题名称",
"duplicateName": "已存在同名主题预设",
"success": "主题预设已创建"
},
"customization": "自定义",
"fontSize": "字体大小",
"borderRadius": "圆角大小",
"fontFamily": "字体",
"systemDefault": "系统默认",
"primaryColor": "主色调",
"textColor": "文本颜色",
"backgroundColor": "背景颜色",
"borderColor": "边框颜色",
"shadowColor": "阴影颜色",
"dividerColor": "分割线颜色",
"translucent": "半透明",
"dark": "暗色",
"light": "亮色",
"searchFont": "搜索字体...",
"fontNotFound": "未找到字体",
"colors": {
"title": "颜色体系",
"base": "基础色调",
"primary": "主色调",
"secondary": "次色调",
"muted": "弱化色调",
"accent": "强调色调",
"destructive": "危险色调",
"cardPopover": "卡片与浮层",
"bordersInputs": "边框与输入",
"background": "背景色",
"foreground": "前景色",
"card": "卡片背景",
"cardForeground": "卡片文本",
"popover": "浮层背景",
"popoverForeground": "浮层文本",
"primaryForeground": "主文本色",
"secondaryForeground": "次文本色",
"mutedForeground": "弱化文本色",
"accentForeground": "强调文本色",
"destructiveForeground": "危险文本色",
"border": "边框色",
"input": "输入框背景",
"ring": "焦点环",
"shadow": "阴影色",
"innerShadow": "内阴影色"
},
"typography": {
"title": "排版"
},
"radius": {
"title": "圆角",
"sm": "小圆角 (sm)",
"md": "中圆角 (md)",
"lg": "大圆角 (lg/组件使用)",
"full": "全圆角 (full)"
},
"shadow": {
"title": "阴影",
"sm": "小阴影 (sm)",
"md": "中阴影 (md)",
"lg": "大阴影 (lg)"
},
"preview": {
"title": "主题预览",
"description": "您的组件和应用将以此主题设置呈现。",
"cardTitle": "示例组件",
"cardDesc": "卡片的描述性副标题。",
"email": "电子邮箱",
"emailPlaceholder": "name@example.com",
"enableNotifications": "启用通知",
"cancel": "取消",
"save": "保存更改",
"buttons": "按钮",
"primary": "主要",
"secondary": "次要",
"destructive": "危险",
"outline": "轮廓",
"ghost": "幽灵",
"inputRing": "输入框与焦点环",
"inputPlaceholder": "点击此处以查看焦点环颜色...",
"slider": "滑块"
}
},
"dashboard": {
"home": "首页",
"dashboard": "仪表盘"
},
"appInfo": {
"title": "应用信息",
"loading": "加载中..."
},
"user": {
"account": "账号",
"logout": "退出登录"
},
"tray": {
"appVersion": "版本",
"systemVersion": "系统",
"runningWidgets": "运行中组件",
"addWidget": "添加组件",
"settings": {
"title": "设置"
},
"checkUpdates": "检查更新",
"shareApp": "分享应用",
"restartWidgets": "重启组件",
"exit": "退出",
"suggestions": "反馈建议",
"downloadLinkCopied": "下载链接已复制",
"infoCopied": "信息已复制",
"restartWidgetsConfirm": "确定要重启所有组件吗?",
"yes": "是",
"no": "否"
},
"notification": {
"enableDevMode": "开发者模式已开启"
},
"drop": {
"title": "安装本地组件包",
"desc": "将组件包.zip拖放到此处即可安装",
"dismiss": "知道了"
},
"update": {
"upgradeToNew": "请升级应用以使用此组件",
"success": "更新成功",
"packageNotConfigured": "未配置组件包信息",
"packageSuccess": "组件包升级成功",
"failed": "更新失败",
"windowTitle": "检查更新",
"alreadyLatestVersion": "当前已是最新版本",
"newVersionDetect": "发现新版本",
"ignore": "忽略",
"storePage": "商店页面",
"update": "更新",
"check": "检查更新"
},
"search": {
"title": "添加组件",
"placeholder": "搜索组件",
"installedManagement": "已安装",
"developerTip": "开发模式已开启",
"devDoc": "开发文档",
"enable": "启用",
"disable": "禁用",
"desktop": "桌面",
"overlap": "悬浮窗",
"tray": "托盘",
"upgrade": "升级",
"installOffline": "安装离线组件",
"desktopShortcut": "桌面图标"
},
"tags": {
"all": "全部",
"ai": "AI",
"tools": "工具",
"productivity": "效率",
"news": "新闻",
"weather": "天气",
"fun": "娱乐",
"calendar": "日历",
"time": "时间",
"finance": "财经",
"photo": "照片",
"installed": "已安装",
"debug": "调试"
},
"feature": {
"wishlist": "心愿单",
"wishlistDesc": "投票选出你希望在下一个版本中看到的功能!"
},
"install": {
"success": "安装成功",
"failed": "安装失败",
"noUrl": "未找到下载链接",
"action": "安装"
},
"error": {
"oops": "哎呀!",
"unexpected": "抱歉,发生了意外错误。",
"unknown": "未知错误",
"backToHome": "返回首页"
},
"aiPage": {
"loadPackagesFailed": "加载套餐失败",
"loadHistoryFailed": "加载历史记录失败",
"tokenPackages": "Token套餐",
"purchasePackage": "购买套餐",
"expirationDate": "过期时间:",
"lifetime": "永久有效",
"used": "已用:",
"total": "总量:",
"noPackages": "暂无可用套餐",
"usageHistory": "使用记录",
"time": "时间",
"model": "模型",
"type": "类型",
"tokens": "Token数",
"remark": "备注",
"loading": "加载中...",
"noHistory": "暂无历史记录"
},
"sizePage": {
"title": "组件大小设置",
"width": "宽度 (Width)",
"height": "高度 (Height)",
"widthDesc": "设置组件的宽度 ({{value}} px),取值范围 {{min}} - {{max}} px。",
"heightDesc": "设置组件的高度 ({{value}} px),取值范围 {{min}} - {{max}} px。"
}
}
================================================
FILE: src/index.css
================================================
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.92 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
.draggable-region {
-webkit-app-region: drag;
}
.draggable-region button,
.draggable-region a {
-webkit-app-region: no-drag;
}
[data-rmiz-modal-overlay="visible"] {
background-color: rgba(0, 0, 0, 0.7) !important;
}
================================================
FILE: src/lib/request.ts
================================================
import axios from 'axios'
import { supabase } from '@/api/supabase'
const service = axios.create({
baseURL: 'https://widgetjs.cn/api/v1',
timeout: 10000,
})
service.interceptors.request.use(
async (config) => {
const { data: { session } } = await supabase.auth.getSession()
if (session?.access_token) {
config.headers.Authorization = `Bearer ${session.access_token}`
}
return config
},
(error) => {
return Promise.reject(error)
},
)
service.interceptors.response.use(
(response) => {
const res = response.data
if (res.code !== 200) {
return Promise.reject(new Error(res.msg || res.message || 'Error'))
}
return res.data
},
(error) => {
return Promise.reject(error)
},
)
export default service
================================================
FILE: src/lib/utils.ts
================================================
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
================================================
FILE: src/main.tsx
================================================
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Toaster } from 'sonner'
import App from './app.tsx'
import './index.css'
import './i18n/config'
createRoot(document.getElementById('root')!).render(
,
)
================================================
FILE: src/model/app-version.ts
================================================
export interface AppVersion {
version: string
releaseNote: string
downloadUrl?: string
downloadLink?: string
force?: boolean
}
================================================
FILE: src/pages/add/add-widget-page.tsx
================================================
import type { WidgetSearchOptions } from '@widget-js/web-api'
import { AppApi, NotificationApi, WidgetApi, WidgetPackageApi } from '@widget-js/core'
import { WebWidget } from '@widget-js/web-api'
import consola from 'consola'
import { FolderDown, Search } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { WebWidgetApi } from '@/api/web-widget-api'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useDebounce } from '@/hooks/use-debounce'
import { SearchItem } from './components/search-item'
import { WidgetTags } from './components/widget-tags'
export default function AddWidgetPage() {
const { t } = useTranslation()
const [keyword, setKeyword] = useState('')
const [selectedCategory, setSelectedCategory] = useState('')
const [widgets, setWidgets] = useState([])
const [_loading, setLoading] = useState(true)
const fileInputRef = useRef(null)
const debouncedKeyword = useDebounce(keyword, 1000)
const search = useCallback(async () => {
setLoading(true)
setWidgets([])
try {
if (selectedCategory === 'installed') {
const widgetPackages = await WidgetPackageApi.getPackages()
const installedPackages = widgetPackages.filter(it => !it.url.startsWith('http') || it.development)
const localWidgets = await WidgetApi.getWidgets()
const newWidgets: WebWidget[] = []
for (const widgetPackage of installedPackages) {
newWidgets.push(
...localWidgets
.map(it => WebWidget.fromObject(it))
.filter(it => it.packageName === widgetPackage.name),
)
}
setWidgets(newWidgets)
setLoading(false)
return
}
const version = await AppApi.getVersion()
const options: WidgetSearchOptions = {
page: 1,
pageSize: 50,
category: selectedCategory,
keyword: debouncedKeyword,
appVersion: version,
}
let localWidgets = (await WidgetApi.getWidgets()).filter(it => !it.disabled)
if (selectedCategory) {
localWidgets = localWidgets.filter(it => it.categories && it.categories.includes(selectedCategory as any))
}
if (debouncedKeyword) {
localWidgets = localWidgets.filter((it) => {
const title = JSON.stringify(it.title)
const description = JSON.stringify(it.description)
return title.includes(debouncedKeyword) || description.includes(debouncedKeyword)
})
}
try {
const res = await WebWidgetApi.search(options)
const remoteWidgets = res.data
.map((it: any) => WebWidget.fromObject(it))
.filter((it: any) => it.name !== 'cn.widgetjs.widgets.dynamic_island')
const mergedWidgets = [...remoteWidgets]
for (const localWidget of localWidgets) {
if (mergedWidgets.some(it => it.name === localWidget.name)) {
continue
}
mergedWidgets.push(WebWidget.fromObject(localWidget))
}
setWidgets(mergedWidgets)
}
catch (e) {
setWidgets(localWidgets.map(it => WebWidget.fromObject(it)))
}
}
catch (e) {
consola.error(e)
}
finally {
setLoading(false)
}
}, [selectedCategory, debouncedKeyword])
useEffect(() => {
search()
}, [search])
useEffect(() => {
document.title = t('search.title')
}, [t])
const handleFileChange = async (event: React.ChangeEvent) => {
const file = event.target.files?.[0]
if (!file) { return }
try {
consola.info(`开始安装组件包: `, file)
await WidgetPackageApi.install((file as any).path)
await NotificationApi.success('安装成功')
window.location.reload()
}
catch (e: any) {
toast.error(`安装失败: ${e.message}`)
}
event.target.value = ''
}
return (
{/* Header */}
{widgets.map(widget => (
))}
)
}
================================================
FILE: src/pages/add/components/feature-wall-list.tsx
================================================
import { Sparkles } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Card, CardContent } from '@/components/ui/card'
export function FeatureWallList() {
const { t } = useTranslation()
return (
{t('feature.wishlist', 'Wishlist Feature')}
{t('feature.wishlistDesc', 'Vote for features you want to see in the next version!')}
)
}
================================================
FILE: src/pages/add/components/search-item.tsx
================================================
import type { WebWidget } from '@widget-js/web-api'
import {
AppApi,
BrowserWindowApi,
DeployedWidgetApi,
DeployMode,
NotificationApi,
WidgetApi,
WidgetPackageUtils,
} from '@widget-js/core'
import { ArrowUpCircle, Loader2 } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import semver from 'semver'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { useDebugConfig } from '@/hooks/use-debug-config'
import { useWidgetPackage } from '@/hooks/use-widget-package'
import WidgetUtil from '@/utils/widget-util'
import WidgetContainer from './widget-container'
interface SearchItemProps {
widget: WebWidget
}
export function SearchItem({ widget }: SearchItemProps) {
const { t, i18n } = useTranslation()
const [isDev] = useDebugConfig()
const [isWidgetHosted, setIsWidgetHosted] = useState(false)
const [adding, setAdding] = useState(false)
const updateWidgetAdd = useCallback(async () => {
if (widget.isSupportBackground()) {
const widgets = await DeployedWidgetApi.getDeployedWidgets(widget.name)
if (widgets) {
setIsWidgetHosted(widgets.length > 0)
}
else {
setIsWidgetHosted(false)
}
}
}, [widget])
useEffect(() => {
updateWidgetAdd()
}, [updateWidgetAdd])
const remotePackage = widget.package
const { upgradable, upgrading, checkUpgrade, upgradePackage } = useWidgetPackage(
widget.packageName!,
remotePackage?.version ?? '0.0.0',
remotePackage?.remote,
)
useEffect(() => {
checkUpgrade()
}, [checkUpgrade])
const removeWidget = async () => {
await DeployedWidgetApi.removeDeployedWidgetByName(widget.name)
await updateWidgetAdd()
}
const openDevTools = () => {
DeployedWidgetApi.openDevTools(widget.name)
}
const addWidget = async (mode: DeployMode) => {
try {
setAdding(true)
let versionPass = true
const appVersion = await AppApi.getVersion('app')
if (widget.requiredAppVersion && appVersion) {
if (semver.gte(appVersion, widget.requiredAppVersion)) {
versionPass = true
}
else {
versionPass = false
NotificationApi.error(t('update.upgradeToNew', 'Please upgrade app'))
}
}
if (versionPass) {
const url = widget.package?.remote
? WidgetPackageUtils.getRemotePackageJsonUrl(widget.package?.remote)
: undefined
await DeployedWidgetApi.addWidget({
widgetName: widget.name,
deployMode: mode,
packageJsonUrl: url,
})
}
}
finally {
setAdding(false)
}
}
const addBackgroundWidget = async () => {
await addWidget(DeployMode.BACKGROUND)
await updateWidgetAdd()
}
const openBackgroundWidgetSettings = async () => {
WidgetApi.openConfigPageByName(widget.name)
}
const onClickAddNormal = async () => {
await addWidget(DeployMode.NORMAL)
}
// Helper to get localized text
const getLocalizedText = (obj: any) => {
if (typeof obj === 'string') { return obj }
if (!obj) { return '' }
return obj[i18n.language] || obj.en || Object.values(obj)[0] || ''
}
const title = getLocalizedText(widget.title)
const description = getLocalizedText(widget.description)
return (
{adding && (
)}
{title}
{description}
{widget.socialLinks && (
{widget.socialLinks.slice(0, 3).map((social: any) => (
BrowserWindowApi.openUrl(social.link, { external: true })}
/>
))}
)}
{widget.isSupportBackground()
? (
<>
{widget.isConfigurable() && (
{t('settings.title', 'Settings')}
)}
{!isWidgetHosted
? (
{t('search.enable', 'Enable')}
)
: (
{t('search.disable', 'Disable')}
)}
{isWidgetHosted && isDev && (
DevTools
)}
DeployedWidgetApi.createDesktopShortcut(widget.name)} className="rounded-full">
{t('search.desktopShortcut', '桌面图标')}
>
)
: (
<>
{widget.isSupportNormal() && (
{t('search.desktop', 'Desktop')}
)}
{widget.isSupportOverlap() && (
addWidget(DeployMode.OVERLAP)} className="rounded-full">
{t('search.overlap', 'Overlap')}
)}
{widget.isSupportTray() && (
addWidget(DeployMode.TRAY)} className="rounded-full">
{t('search.tray', 'Tray')}
)}
>
)}
{upgradable && (
{upgrading ? : }
{t('search.upgrade', 'Upgrade')}
)}
)
}
================================================
FILE: src/pages/add/components/widget-container.tsx
================================================
import type { WebWidget } from '@widget-js/web-api'
import { WidgetApi } from '@widget-js/core'
import { Image as ImageIcon, ImageOff } from 'lucide-react'
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import { ZoomImage } from './zoom-image'
interface WidgetContainerProps {
widget: WebWidget
className?: string
}
export default function WidgetContainer({ widget, className }: WidgetContainerProps) {
const [previewImage, setPreviewImage] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const cellSize = 72
const containerHeight = cellSize * 2
useEffect(() => {
const loadPreview = async () => {
if (!widget.previewImage) {
setLoading(false)
return
}
try {
setLoading(true)
setError(false)
if (widget.package && widget.package.remote) {
const remote = widget.package.remote
const url = `https://${remote.hostname}${remote.base}${widget.previewImage}`
setPreviewImage(url)
}
else if (widget.packageName) {
const url = await WidgetApi.getWidgetPackageUrl(widget.packageName)
setPreviewImage(url + widget.previewImage)
}
}
catch (e) {
console.error('Failed to load preview image', e)
setError(true)
}
finally {
setLoading(false)
}
}
loadPreview()
}, [widget])
return (
{previewImage
? (
setError(true)}
style={{ display: error ? 'none' : 'block' }}
/>
)
: null}
{/* Fallback states */}
{(error || (!previewImage && !loading)) && (
Preview unavailable
)}
{loading && (
Loading...
)}
)
}
================================================
FILE: src/pages/add/components/widget-tags.tsx
================================================
import {
Bot,
Calendar,
Clock,
Download,
Gamepad2,
LayoutGrid,
Newspaper,
Sun,
TrendingUp,
Wrench,
Zap,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
interface WidgetTagsProps {
value: string
onChange: (value: string) => void
className?: string
}
export function WidgetTags({ value, onChange, className }: WidgetTagsProps) {
const { t } = useTranslation()
const tags = [
{ icon: LayoutGrid, label: t('tags.all', 'All'), value: '' },
{ icon: Bot, label: t('tags.ai', 'AI'), value: 'ai' },
{ icon: Wrench, label: t('tags.tools', 'Utilities'), value: 'utilities' },
{ icon: Zap, label: t('tags.productivity', 'Productivity'), value: 'productivity' },
{ icon: Newspaper, label: t('tags.news', 'News'), value: 'news' },
{ icon: Sun, label: t('tags.weather', 'Weather'), value: 'weather' },
{ icon: Gamepad2, label: t('tags.fun', 'Fun'), value: 'fun' },
{ icon: Calendar, label: t('tags.calendar', 'Calendar'), value: 'calendar' },
{ icon: Clock, label: t('tags.time', 'Time'), value: 'time' },
{ icon: TrendingUp, label: t('tags.finance', 'Finance'), value: 'finance' },
{ icon: Download, label: t('tags.installed', 'Installed'), value: 'installed' },
]
return (
{tags.map(tag => (
onChange(tag.value)}
className={cn(
'whitespace-nowrap rounded-full px-4 gap-2 transition-all duration-200',
value === tag.value ? 'shadow-md' : 'hover:bg-secondary/80',
)}
>
{tag.label}
))}
)
}
================================================
FILE: src/pages/add/components/zoom-image.tsx
================================================
import { useCallback, useState } from 'react'
import { Controlled as ControlledZoom } from 'react-medium-image-zoom'
import 'react-medium-image-zoom/dist/styles.css'
interface ZoomImageProps {
src: string
alt?: string
className?: string
onError?: () => void
style?: React.CSSProperties
}
export function ZoomImage({ src, alt, className, onError, style }: ZoomImageProps) {
const [isZoomed, setIsZoomed] = useState(false)
const handleZoomChange = useCallback((shouldZoom: boolean) => {
setIsZoomed(shouldZoom)
}, [])
return (
<>
>
)
}
================================================
FILE: src/pages/ai/ai-page.tsx
================================================
import type { AiTokenHistory, AiTokenPackage } from '@/api/ai'
import { History, Package, ShoppingCart } from 'lucide-react'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { AiApi } from '@/api/ai'
import { LoginCheck } from '@/components/login-check'
import { PurchaseDialog } from '@/components/purchase-dialog'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useUser } from '@/hooks/use-user'
export default function AiPage() {
const { t } = useTranslation()
const { user } = useUser()
const [packages, setPackages] = useState([])
const [history, setHistory] = useState([])
const [historyTotal, setHistoryTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(10)
const [loading, setLoading] = useState(false)
// Purchase Dialog State
const [purchaseOpen, setPurchaseOpen] = useState(false)
const loadPackages = async () => {
try {
const res = await AiApi.getPackages({ page: 1, limit: 3 })
setPackages(res.items)
}
catch (error) {
console.error(error)
toast.error(t('aiPage.loadPackagesFailed', '加载套餐失败'))
}
}
useEffect(() => {
if (user) {
loadPackages()
}
}, [user])
const loadHistory = async () => {
try {
setLoading(true)
const res = await AiApi.getUsage({ page, limit: pageSize })
setHistory(res.items)
setHistoryTotal(res.total)
}
catch (error) {
console.error(error)
toast.error(t('aiPage.loadHistoryFailed', '加载历史记录失败'))
}
finally {
setLoading(false)
}
}
const totalPages = Math.ceil(historyTotal / pageSize)
useEffect(() => {
if (user) {
loadHistory()
}
}, [page, user])
return (
{/* Packages Section */}
{t('aiPage.tokenPackages', 'Token套餐')}
setPurchaseOpen(true)}
>
{t('aiPage.purchasePackage', '购买套餐')}
{packages.map(pkg => (
{pkg.name}
{t('aiPage.expirationDate', '过期时间:')}
{' '}
{pkg.expireTime ? new Date(pkg.expireTime).toLocaleDateString() : t('aiPage.lifetime', '永久有效')}
{t('aiPage.used', '已用:')}
{pkg.usedToken.toLocaleString()}
{t('aiPage.total', '总量:')}
{pkg.maxToken.toLocaleString()}
))}
{packages.length === 0 && (
{t('aiPage.noPackages', '暂无可用套餐')}
)}
{/* History Section */}
{t('aiPage.usageHistory', '使用记录')}
{t('aiPage.time', '时间')}
{t('aiPage.model', '模型')}
{t('aiPage.type', '类型')}
{t('aiPage.tokens', 'Token数')}
{t('aiPage.remark', '备注')}
{loading
? (
{t('aiPage.loading', '加载中...')}
)
: history.length > 0
? (
history.map(item => (
{new Date(item.create_time).toLocaleString()}
{item.model || '-'}
{item.request_type || '-'}
{item.total_tokens?.toLocaleString() || 0}
{item.remark || '-'}
))
)
: (
{t('aiPage.noHistory', '暂无历史记录')}
)}
{/* Pagination */}
{totalPages > 1 && (
{
e.preventDefault()
if (page > 1) { setPage(p => p - 1) }
}}
className={page <= 1 ? 'pointer-events-none opacity-50' : ''}
/>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
.map((p, i, arr) => {
// Add ellipsis if there are gaps
const prev = arr[i - 1]
const showEllipsis = prev && p - prev > 1
return (
{showEllipsis && (
...
)}
{
e.preventDefault()
setPage(p)
}}
>
{p}
)
})}
{
e.preventDefault()
if (page < totalPages) { setPage(p => p + 1) }
}}
className={page >= totalPages ? 'pointer-events-none opacity-50' : ''}
/>
)}
)
}
================================================
FILE: src/pages/dev/dev-page.tsx
================================================
import { BrowserWindowApi, WidgetApi, WidgetPackageApi } from '@widget-js/core'
import { WebWidget } from '@widget-js/web-api'
import { Hammer } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Empty,
EmptyContent,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Spinner } from '@/components/ui/spinner'
import { SearchItem } from '@/pages/add/components/search-item'
export default function DevPage() {
const { t } = useTranslation()
const [widgets, setWidgets] = useState([])
const [loading, setLoading] = useState(true)
const openDocs = () => {
BrowserWindowApi.openUrl('https://widgetjs.cn/guide/', { external: true })
}
useEffect(() => {
document.title = t('sidebar.dev')
}, [t])
useEffect(() => {
const loadWidgets = async () => {
try {
const widgetPackages = await WidgetPackageApi.getPackages()
const devPackages = widgetPackages.filter(it => it.development)
const localWidgets = await WidgetApi.getWidgets()
const devWidgets: WebWidget[] = []
for (const widgetPackage of devPackages) {
const matchingWidgets = localWidgets
.map(it => WebWidget.fromObject(it))
.filter(it => it.packageName === widgetPackage.name)
devWidgets.push(...matchingWidgets)
}
setWidgets(devWidgets)
}
catch (e) {
console.error('Failed to load widgets', e)
}
finally {
setLoading(false)
}
}
loadWidgets()
}, [])
if (loading) {
return (
)
}
if (widgets.length > 0) {
return (
{widgets.map(widget => (
))}
)
}
return (
{t('dev.emptyTitle')}
{t('dev.viewDocs')}
)
}
================================================
FILE: src/pages/error-page.tsx
================================================
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useRouteError } from 'react-router-dom'
import { Button } from '@/components/ui/button'
export default function ErrorPage() {
const { t } = useTranslation()
const error: any = useRouteError()
const location = useLocation()
console.error(error)
return (
{t('error.oops')}
{t('error.unexpected')}
{error?.statusText || error?.message || t('error.unknown')}
{' '}
{location.pathname}
{t('error.backToHome')}
)
}
================================================
FILE: src/pages/packages/components/widget-package-item.tsx
================================================
import type { LanguageCode, WidgetPackage } from '@widget-js/core'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { useAppLanguage } from '@/hooks/use-app-language'
interface WidgetPackageItemProps {
widgetPackage: WidgetPackage
onUninstall: (pkg: WidgetPackage) => void
}
export function WidgetPackageItem({ widgetPackage, onUninstall }: WidgetPackageItemProps) {
const { t } = useTranslation()
const [languageCode] = useAppLanguage()
const getTitle = (): string => {
if (typeof widgetPackage.getTitle === 'function') {
return widgetPackage.getTitle(languageCode as LanguageCode) || widgetPackage.name
}
return widgetPackage.name
}
return (
{t('settings.widgetPackage.title')}
{getTitle()}
{t('settings.widgetPackage.name')}
{widgetPackage.name}
{t('settings.widgetPackage.installPath')}
{widgetPackage.url}
{t('settings.widgetPackage.uninstall')}
{t('settings.widgetPackage.uninstallConfirm')}
{t('settings.widgetPackage.uninstallDesc', { name: getTitle() })}
{t('settings.widgetPackage.cancel')}
onUninstall(widgetPackage)}>
{t('settings.widgetPackage.confirm')}
)
}
================================================
FILE: src/pages/packages/widget-package-manager-page.tsx
================================================
import { WidgetPackage, WidgetPackageApi } from '@widget-js/core'
import consola from 'consola'
import { useEffect, useState } from 'react'
import { WidgetPackageItem } from './components/widget-package-item'
export default function WidgetPackageManagerPage() {
const [packages, setPackages] = useState([])
const loadPackages = async () => {
try {
const list = await WidgetPackageApi.getPackages()
const parsedList = list.map((it) => {
if (WidgetPackage.parseObject) {
return WidgetPackage.parseObject(it)
}
return it as unknown as WidgetPackage
})
setPackages(parsedList)
}
catch (e) {
consola.error('Failed to load packages', e)
}
}
const handleUninstall = async (pkg: WidgetPackage) => {
try {
await WidgetPackageApi.uninstall(pkg.name)
setPackages(prev => prev.filter(it => it.name !== pkg.name))
}
catch (e) {
consola.error('Failed to uninstall package', e)
}
}
useEffect(() => {
loadPackages()
}, [])
return (
{packages
.filter(item => item.name !== 'cn.widgetjs.widgets')
.map(item => (
))}
)
}
================================================
FILE: src/pages/settings/app-info-page.tsx
================================================
import type { AppVersion } from '@/model/app-version'
import { ElectronUtils, SystemApi } from '@widget-js/core'
import consola from 'consola'
import { Copy } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Spinner } from '@/components/ui/spinner'
import { useAppRuntimeInfo } from '@/hooks/use-app-runtime-info'
import VersionUtils from '@/utils/version-utils'
export default function AppInfoPage() {
const { t } = useTranslation()
const { simpleInfo } = useAppRuntimeInfo()
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [appVersion, setAppVersion] = useState(null)
const [hasNewVersion, setHasNewVersion] = useState(false)
const checkNewVersion = () => {
setLoading(true)
setError(null)
setAppVersion(null)
setHasNewVersion(false)
VersionUtils.checkNewVersion(
(version: AppVersion) => {
setAppVersion(version)
setHasNewVersion(true)
},
(err: any) => {
setError(err.message || 'Check update failed')
consola.error(err)
},
() => {
setLoading(false)
},
)
}
// Initial check on mount
useEffect(() => {
document.title = t('appInfo.title')
checkNewVersion()
}, [t])
const upgrade = async () => {
if (!appVersion) { return }
setLoading(true)
try {
await ElectronUtils.getAPI().invoke('upgradeApp', JSON.stringify(appVersion))
}
catch (e) {
consola.error(e)
setError('Upgrade failed')
}
finally {
setLoading(false)
}
}
const renderValue = (value: any) => {
if (typeof value === 'object' && value !== null) {
return JSON.stringify(value)
}
return String(value)
}
const copyInfo = () => {
if (!simpleInfo) { return }
const text = Object.entries(simpleInfo)
.map(([key, value]) => `${key}: ${renderValue(value)}`)
.join('\n')
navigator.clipboard.writeText(text)
toast.success(t('tray.infoCopied'))
}
return (
{t('appInfo.title')}
{simpleInfo
? (
Object.keys(simpleInfo).map(key => (
{key}
:
{renderValue((simpleInfo as any)[key])}
))
)
: (
)}
{t('update.windowTitle')}
{loading
? (
)
: error
? (
{error}
)
: hasNewVersion && appVersion
? (
{t('update.newVersionDetect')}
{' '}
{appVersion.version}
{appVersion.releaseNote}
)
: (
{t('update.alreadyLatestVersion')}
)}
SystemApi.launchStoreDetailsPage()}>
{t('update.storePage')}
{!loading && (
hasNewVersion
? (
{t('update.update')}
)
: (
{t('update.check')}
)
)}
)
}
================================================
FILE: src/pages/settings/components/app-theme-form.tsx
================================================
import type { AppTheme, ThemeColors } from '@widget-js/core'
import { Sketch } from '@uiw/react-color'
import { useTranslation } from 'react-i18next'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { FontFamilyPicker } from './font-family-picker'
interface AppThemeFormProps {
value: AppTheme
onChange: (value: AppTheme) => void
}
export function AppThemeForm({ value, onChange }: AppThemeFormProps) {
const { t } = useTranslation()
const handleNestedChange = (category: keyof AppTheme, key: string, val: any) => {
const newTheme = value.copy({
[category]: {
[key]: val,
},
})
onChange(newTheme)
}
const renderColorInput = (label: string, key: keyof ThemeColors) => (
{label}
{value.colors?.[key] as string}
{
// color.hexa always exists and contains the alpha channel if supported by the picker
// e.g. "#00000080" for 50% opacity
handleNestedChange('colors', key, color.hexa || color.hex)
}}
/>
)
const renderBoxShadowInput = (key: 'sm' | 'md' | 'lg', label: string) => {
const shadow = value.shadow?.[key] || { offsetX: '0px', offsetY: '0px', blur: '0px' }
const handleChange = (field: keyof typeof shadow, val: string) => {
const newTheme = value.copy({
shadow: {
...value.shadow,
[key]: {
...shadow,
[field]: val,
},
},
})
onChange(newTheme)
}
return (
)
}
const renderSlider = (category: keyof AppTheme, key: string, label: string, min: number, max: number) => {
const valStr = (value as any)[category]?.[key] as string || '0'
const numVal = Number.parseFloat(valStr) || 0
const unit = valStr.replace(/[0-9.]/g, '') || 'px'
return (
{label}
{numVal}
{unit}
handleNestedChange(category, key, `${val}${unit}`)}
/>
)
}
return (
{t('theme.colors.title')}
{t('theme.typography.title')}
{t('theme.radius.title')}
{t('theme.shadow.title')}
{t('theme.colors.base')}
{renderColorInput(t('theme.colors.background'), 'background')}
{renderColorInput(t('theme.colors.foreground'), 'foreground')}
{renderColorInput(t('theme.colors.border'), 'border')}
{renderColorInput(t('theme.colors.shadow'), 'shadow')}
{renderColorInput(t('theme.colors.innerShadow'), 'innerShadow')}
{t('theme.colors.primary')}
{renderColorInput(t('theme.colors.primary'), 'primary')}
{renderColorInput(t('theme.colors.primaryForeground'), 'primaryForeground')}
{t('theme.colors.secondary')}
{renderColorInput(t('theme.colors.secondary'), 'secondary')}
{renderColorInput(t('theme.colors.secondaryForeground'), 'secondaryForeground')}
{t('theme.colors.muted')}
{renderColorInput(t('theme.colors.muted'), 'muted')}
{renderColorInput(t('theme.colors.mutedForeground'), 'mutedForeground')}
{t('theme.colors.accent')}
{renderColorInput(t('theme.colors.accent'), 'accent')}
{renderColorInput(t('theme.colors.accentForeground'), 'accentForeground')}
{t('theme.colors.destructive')}
{renderColorInput(t('theme.colors.destructive'), 'destructive')}
{renderColorInput(t('theme.colors.destructiveForeground'), 'destructiveForeground')}
{t('theme.colors.cardPopover')}
{renderColorInput(t('theme.colors.card'), 'card')}
{renderColorInput(t('theme.colors.cardForeground'), 'cardForeground')}
{renderColorInput(t('theme.colors.popover'), 'popover')}
{renderColorInput(t('theme.colors.popoverForeground'), 'popoverForeground')}
{t('theme.colors.bordersInputs')}
{renderColorInput(t('theme.colors.input'), 'input')}
{renderColorInput(t('theme.colors.ring'), 'ring')}
handleNestedChange('typography', 'fontFamily', val)}
/>
{renderSlider('typography', 'fontSize', t('theme.fontSize'), 8, 50)}
{renderSlider('radius', 'sm', t('theme.radius.sm'), 0, 50)}
{renderSlider('radius', 'md', t('theme.radius.md'), 0, 50)}
{renderSlider('radius', 'lg', t('theme.radius.lg'), 0, 50)}
{renderSlider('radius', 'full', t('theme.radius.full'), 0, 9999)}
{renderBoxShadowInput('sm', t('theme.shadow.sm'))}
{renderBoxShadowInput('md', t('theme.shadow.md'))}
{renderBoxShadowInput('lg', t('theme.shadow.lg'))}
)
}
================================================
FILE: src/pages/settings/components/font-family-picker.tsx
================================================
import { Check, ChevronsUpDown, Loader2, Search } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
function getDefaultFonts(t: any) {
return [
{ label: t('theme.systemDefault'), value: 'system-ui, sans-serif' },
]
}
interface FontFamilyPickerProps {
value: string
onChange: (val: string) => void
label: string
}
export function FontFamilyPicker({ value, onChange, label }: FontFamilyPickerProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const defaultFonts = useMemo(() => getDefaultFonts(t), [t])
const [fonts, setFonts] = useState(defaultFonts)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
const loadSystemFonts = async () => {
try {
if ('queryLocalFonts' in window) {
setIsLoading(true)
// Request permission and query local fonts
const localFonts = await (window as any).queryLocalFonts()
const systemFonts = localFonts.map((font: any) => ({
label: font.fullName,
value: `"${font.family}"`,
}))
// Filter out duplicates (based on family name) and merge with defaults
const uniqueSystemFonts = Array.from(new Map(systemFonts.map((item: any) => [item.value, item])).values()) as typeof defaultFonts
// Merge defaults with system fonts, prioritizing defaults at the top
const mergedFonts = [...defaultFonts]
for (const sysFont of uniqueSystemFonts) {
if (!mergedFonts.some(f => f.value.includes(sysFont.value.replace(/"/g, '')))) {
mergedFonts.push(sysFont)
}
}
setFonts(mergedFonts)
}
}
catch (error) {
console.error('Failed to load system fonts:', error)
}
finally {
setIsLoading(false)
}
}
if (open && fonts.length === defaultFonts.length) {
loadSystemFonts()
}
}, [open, fonts.length, defaultFonts])
const filteredFonts = fonts.filter(f =>
f.label.toLowerCase().includes(search.toLowerCase())
|| f.value.toLowerCase().includes(search.toLowerCase()),
)
const currentFont = fonts.find(f => f.value === value) || { label: value || 'Default', value }
return (
{label}
{currentFont.label}
setSearch(e.target.value)}
/>
{isLoading
? (
加载中...
)
: filteredFonts.length === 0
? (
{t('theme.fontNotFound')}
)
: (
filteredFonts.map(font => (
{
onChange(font.value)
setOpen(false)
}}
>
{value === font.value && }
{font.label}
))
)}
)
}
================================================
FILE: src/pages/settings/components/theme-preview.tsx
================================================
import type { AppTheme } from '@widget-js/core'
import { useTranslation } from 'react-i18next'
import wallpaper from '@/assets/images/wallpaper.jpg'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
interface ThemePreviewProps {
theme: AppTheme
}
export function ThemePreview({ theme }: ThemePreviewProps) {
const { t } = useTranslation()
// Convert theme object to inline styles for the preview container
const cssVars = theme.toCSSVariables('--widget')
return (
{/* We apply the custom CSS variables to this specific container */}
{/* Example Form Preview */}
{t('theme.preview.cardTitle')}
{t('theme.preview.cardDesc')}
{t('theme.preview.cancel')}
{t('theme.preview.save')}
{/* Interactive Elements Preview */}
{t('theme.preview.buttons')}
{t('theme.preview.primary')}
{t('theme.preview.secondary')}
{t('theme.preview.destructive')}
{t('theme.preview.outline')}
{t('theme.preview.ghost')}
{t('theme.preview.slider')}
)
}
================================================
FILE: src/pages/settings/components/theme-tags.tsx
================================================
import type { FormEvent } from 'react'
import { AppTheme } from '@widget-js/core'
import { Check, Plus } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
export interface ThemeTag {
name: string
value: string
theme: AppTheme
}
const dark = new AppTheme({
useGlobalTheme: true,
mode: 'dark',
colors: {
background: '#000026',
foreground: '#f1f1f1',
card: '#111113',
cardForeground: '#FAFAFA',
popover: '#111113',
popoverForeground: '#FAFAFA',
primary: '#7C8CF8',
primaryForeground: '#0E0E10',
secondary: '#1A1A1D',
secondaryForeground: '#E4E4E7',
muted: '#1A1A1D',
mutedForeground: '#A1A1AA',
accent: '#1E1F25',
accentForeground: '#C7D2FE',
destructive: '#FF6369',
destructiveForeground: '#0E0E10',
border: '#6f6f94',
input: '#27272A',
ring: '#7C8CF8',
shadow: 'rgba(0,0,0,0.5)',
innerShadow: 'rgb(0 0 0 / 0.11)',
},
radius: {
sm: '6px',
md: '10px',
lg: '14px',
full: '9999px',
},
typography: {
fontFamily: `Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`,
fontSize: '14px',
},
shadow: {
sm: { offsetX: '0px', offsetY: '1px', blur: '2px' },
md: { offsetX: '0px', offsetY: '6px', blur: '20px' },
lg: { offsetX: '0px', offsetY: '20px', blur: '40px' },
},
spacing: '0.25rem',
})
const light = new AppTheme({
useGlobalTheme: true,
mode: 'light',
colors: {
background: '#ffffff',
foreground: '#272e39',
card: '#FFFFFF',
cardForeground: '#0A0A0A',
popover: '#FFFFFF',
popoverForeground: '#0A0A0A',
primary: '#5E6AD2',
primaryForeground: '#FFFFFF',
secondary: '#F4F4F5',
secondaryForeground: '#18181B',
muted: '#F9F9FB',
mutedForeground: '#71717A',
accent: '#EEF2FF',
accentForeground: '#4338CA',
destructive: '#E5484D',
destructiveForeground: '#FFFFFF',
border: '#E4E4E7',
input: '#E4E4E7',
ring: '#5E6AD2',
shadow: 'rgba(0,0,0,0.04)',
innerShadow: 'rgba(0,0,0,0.02)',
},
radius: {
sm: '6px',
md: '10px',
lg: '14px',
full: '9999px',
},
typography: {
fontFamily: `Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`,
fontSize: '14px',
},
shadow: {
sm: { offsetX: '0px', offsetY: '1px', blur: '2px' },
md: { offsetX: '0px', offsetY: '4px', blur: '12px' },
lg: { offsetX: '0px', offsetY: '10px', blur: '30px' },
},
spacing: '0.25rem',
})
const semiTransparent = new AppTheme({
mode: 'dark',
colors: {
background: 'rgba(0, 0, 0, 0.5)',
card: 'rgba(0, 0, 0, 0.5)',
popover: 'rgba(0, 0, 0, 0.5)',
border: 'rgb(145 145 145 / 0.5)',
innerShadow: 'rgb(208 208 208 / 0.25)',
},
})
interface ThemeTagsProps {
value: string
presets: ThemeTag[]
onChange: (value: ThemeTag) => void
onCreatePreset: (name: string) => boolean
}
export function ThemeTags({ value, presets, onChange, onCreatePreset }: ThemeTagsProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [presetName, setPresetName] = useState('')
const themes: ThemeTag[] = [
...presets,
{ name: t('theme.dark', '深色'), value: 'dark', theme: dark },
{ name: t('theme.light', '浅色'), value: 'light', theme: light },
{ name: t('theme.translucent', '半透明'), value: 'semi-transparent', theme: semiTransparent },
]
const handleSubmit = (event: FormEvent) => {
event.preventDefault()
if (onCreatePreset(presetName)) {
setOpen(false)
setPresetName('')
}
}
return (
{
setOpen(nextOpen)
if (!nextOpen) {
setPresetName('')
}
}}
>
setOpen(true)}
aria-label={t('theme.createPreset.button')}
>
{themes.map(item => (
onChange(item)}
className={cn(
'inline-flex items-center justify-center rounded-full px-4 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background',
value === item.value
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
)}
>
{item.name}
{value === item.value && }
))}
)
}
================================================
FILE: src/pages/settings/general-page.tsx
================================================
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingSection } from '@/components/setting-section'
import { SocialLinks } from '@/components/tray/social-links'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Field, FieldLabel } from '@/components/ui/field'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { useAppLanguage } from '@/hooks/use-app-language'
import { useCellSizeConfig } from '@/hooks/use-cell-size-config'
import { useDebugConfig } from '@/hooks/use-debug-config'
import { useLaunchAtStartupConfig } from '@/hooks/use-launch-at-startup-config'
interface Language {
baseName: string
language: string
nativeName: string
region: string
}
const languages: Language[] = [
{ baseName: 'zh-CN', language: 'zh', nativeName: '简体中文', region: 'CN' },
{ baseName: 'en-US', language: 'en', nativeName: 'English', region: 'US' },
]
export default function GeneralPage() {
const { t, i18n } = useTranslation()
const [launchAtStartup, setLaunchAtStartup] = useLaunchAtStartupConfig()
const [languageCode, setLanguageCode] = useAppLanguage({
onLoad: (lang) => {
if (lang !== i18n.language) {
i18n.changeLanguage(lang)
}
},
})
const [debugMode, setDebugMode] = useDebugConfig()
const [gridSize, setGridSize] = useCellSizeConfig()
useEffect(() => {
document.title = t('general.title')
}, [t])
useEffect(() => {
if (languageCode !== i18n.language) {
i18n.changeLanguage(languageCode)
}
}, [languageCode, i18n])
return (
{t('general.title')}
{t('general.language')}
{languages.map(lang => (
{lang.nativeName}
))}
{gridSize != null && (
{t('general.gridSize')}
setGridSize(Number.parseInt(val, 10))}
className="flex gap-4"
>
{[60, 70, 80, 90].map(size => (
{size}
px
))}
)}
{t('general.launchAtStartup')}
{t('general.developerMode')}
)
}
================================================
FILE: src/pages/settings/proxy-page.tsx
================================================
import type { ProxyConfig } from '@widget-js/core'
import { AppApi } from '@widget-js/core'
import consola from 'consola'
import { AlertTriangle } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { useDebounce } from '@/hooks/use-debounce'
export default function ProxyPage() {
const { t } = useTranslation()
const [protocol, setProtocol] = useState('http')
const [hostname, setHostname] = useState('')
const [port, setPort] = useState('')
const debouncedProtocol = useDebounce(protocol, 1000)
const debouncedHostname = useDebounce(hostname, 1000)
const debouncedPort = useDebounce(port, 1000)
// Load initial proxy settings
useEffect(() => {
AppApi.getProxy().then((value) => {
consola.info('proxy', value)
if (value && value.proxyRules) {
try {
const url = new URL(value.proxyRules)
setProtocol(url.protocol.replace(':', ''))
setHostname(url.hostname)
setPort(url.port)
}
catch (e) {
consola.error(e)
}
}
})
}, [])
// Update proxy settings when debounced values change
useEffect(() => {
const updateProxy = async () => {
if (debouncedProtocol && debouncedHostname && debouncedPort) {
const proxyRules = `${debouncedProtocol}://${debouncedHostname}:${debouncedPort}`
const proxyConfig: ProxyConfig = {
proxyRules,
}
await AppApi.setProxy(proxyConfig)
}
}
updateProxy()
}, [debouncedProtocol, debouncedHostname, debouncedPort])
const clearProxy = async () => {
setProtocol('')
setHostname('')
setPort('')
await AppApi.setProxy({})
}
return (
)
}
================================================
FILE: src/pages/settings/theme-page.tsx
================================================
import type { IAppTheme } from '@widget-js/core'
import type { ThemeTag } from '@/pages/settings/components/theme-tags'
import { AppApi, AppTheme } from '@widget-js/core'
import consola from 'consola'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useDebounce } from '@/hooks/use-debounce'
import { AppThemeForm } from '@/pages/settings/components/app-theme-form'
import { ThemePreview } from '@/pages/settings/components/theme-preview'
import { ThemeTags } from '@/pages/settings/components/theme-tags'
interface StoredThemeTag {
name: string
value: string
theme: IAppTheme
}
const SELECT_THEME_STORAGE_KEY = 'selectTheme'
const THEME_PRESETS_STORAGE_KEY = 'widget.theme.presets'
function loadStoredThemeTags() {
try {
const storedValue = localStorage.getItem(THEME_PRESETS_STORAGE_KEY)
if (!storedValue) {
return []
}
const parsed = JSON.parse(storedValue) as StoredThemeTag[]
if (!Array.isArray(parsed)) {
return []
}
return parsed.map(item => ({
name: item.name,
value: item.value,
theme: new AppTheme(item.theme),
}))
}
catch (error) {
consola.error('Failed to load theme presets:', error)
return []
}
}
function saveStoredThemeTags(tags: ThemeTag[]) {
const payload: StoredThemeTag[] = tags.map(tag => ({
name: tag.name,
value: tag.value,
theme: JSON.parse(JSON.stringify(tag.theme)) as IAppTheme,
}))
localStorage.setItem(THEME_PRESETS_STORAGE_KEY, JSON.stringify(payload))
}
export default function ThemePage() {
const { t } = useTranslation()
const [appTheme, setAppTheme] = useState(new AppTheme())
const [customThemeTags, setCustomThemeTags] = useState([])
const [selectedThemeTag, setSelectedThemeTag] = useState('dark')
const isFirstRun = useRef(true)
// Use debounced theme for saving to avoid excessive API calls
const debouncedTheme = useDebounce(appTheme, 500)
// Load initial theme
useEffect(() => {
const loadTheme = async () => {
try {
const css = await AppApi.getThemeCSS()
if (css) {
const loadedTheme = AppTheme.fromCSS(css)
setAppTheme(loadedTheme)
consola.info('Loaded app theme:', loadedTheme)
}
const storedThemeTags = loadStoredThemeTags()
setCustomThemeTags(storedThemeTags)
const storedTag = localStorage.getItem(SELECT_THEME_STORAGE_KEY)
if (storedTag) {
setSelectedThemeTag(storedTag)
}
}
catch (error) {
consola.error('Failed to load theme:', error)
}
finally {
isFirstRun.current = false
}
}
loadTheme()
}, [])
// Save theme when it changes (debounced)
useEffect(() => {
if (isFirstRun.current) { return }
const saveTheme = async () => {
try {
const css = debouncedTheme.toCSS(':root')
consola.info('Saving new CSS:', css)
await AppApi.setThemeCSS(css)
}
catch (error) {
consola.error('Failed to save theme:', error)
}
}
saveTheme()
}, [debouncedTheme])
const handleThemeTagChange = (tag: ThemeTag) => {
setSelectedThemeTag(tag.value)
localStorage.setItem(SELECT_THEME_STORAGE_KEY, tag.value)
const newTheme = appTheme.copy(tag.theme)
setAppTheme(newTheme)
}
const handleThemeChange = (newTheme: AppTheme) => {
setAppTheme(newTheme)
setCustomThemeTags((prevTags) => {
const isCustomPreset = prevTags.some(tag => tag.value === selectedThemeTag)
if (isCustomPreset) {
const nextTags = prevTags.map(tag =>
tag.value === selectedThemeTag ? { ...tag, theme: newTheme.copy() } : tag,
)
saveStoredThemeTags(nextTags)
return nextTags
}
return prevTags
})
}
const handleCreateThemePreset = (name: string) => {
const presetName = name.trim()
if (!presetName) {
toast.error(t('theme.createPreset.emptyName'))
return false
}
const hasSameName = customThemeTags.some(tag => tag.name.toLocaleLowerCase() === presetName.toLocaleLowerCase())
if (hasSameName) {
toast.error(t('theme.createPreset.duplicateName'))
return false
}
const newPreset: ThemeTag = {
name: presetName,
value: `preset-${Date.now()}`,
theme: appTheme.copy(),
}
const nextThemeTags = [...customThemeTags, newPreset]
setCustomThemeTags(nextThemeTags)
saveStoredThemeTags(nextThemeTags)
setSelectedThemeTag(newPreset.value)
localStorage.setItem(SELECT_THEME_STORAGE_KEY, newPreset.value)
toast.success(t('theme.createPreset.success'))
return true
}
return (
{t('theme.preview.title')}
{t('theme.preview.description')}
{t('theme.presets')}
)
}
================================================
FILE: src/pages/size/size-page.tsx
================================================
import type { BroadcastEvent } from '@widget-js/core'
import { DeployedWidgetApi, WidgetApiEvent } from '@widget-js/core'
import { useAppBroadcast, WindowControls } from '@widget-js/react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useSearchParams } from 'react-router-dom'
import { Field, FieldDescription, FieldTitle } from '@/components/ui/field'
import { Slider } from '@/components/ui/slider'
export default function SizePage() {
const { t } = useTranslation()
const [searchParams] = useSearchParams()
const widgetId = searchParams.get('widgetId')
useAppBroadcast([WidgetApiEvent.WIDGET_REMOVED], (event: BroadcastEvent) => {
if (event.event === WidgetApiEvent.WIDGET_REMOVED && event.payload?.widgetId === widgetId) {
window.close()
}
})
const [width, setWidth] = useState(0)
const [height, setHeight] = useState(0)
const [minWidth, setMinWidth] = useState(50)
const [maxWidth, setMaxWidth] = useState(1000)
const [minHeight, setMinHeight] = useState(50)
const [maxHeight, setMaxHeight] = useState(1000)
const [loading, setLoading] = useState(true)
useEffect(() => {
document.title = t('sizePage.title', '组件大小设置')
}, [t])
useEffect(() => {
const loadWidgetInfo = async () => {
if (!widgetId) { return }
try {
const widget = await DeployedWidgetApi.getDeployedWidget(widgetId)
if (widget) {
setWidth(widget.width || 0)
setHeight(widget.height || 0)
if (widget.minWidth) { setMinWidth(widget.minWidth) }
if (widget.maxWidth) { setMaxWidth(widget.maxWidth) }
if (widget.minHeight) { setMinHeight(widget.minHeight) }
if (widget.maxHeight) { setMaxHeight(widget.maxHeight) }
}
}
catch (error) {
console.error('Failed to load widget info:', error)
}
finally {
setLoading(false)
}
}
loadWidgetInfo()
}, [widgetId])
const handleWidthChange = async (value: number[]) => {
const newWidth = value[0]
setWidth(newWidth)
if (widgetId) {
await DeployedWidgetApi.setSize(widgetId, newWidth, height)
}
}
const handleHeightChange = async (value: number[]) => {
const newHeight = value[0]
setHeight(newHeight)
if (widgetId) {
await DeployedWidgetApi.setSize(widgetId, width, newHeight)
}
}
if (loading) {
return Loading...
}
if (!widgetId) {
return Missing widgetId parameter
}
return (
{t('sizePage.width', '宽度 (Width)')}
{t('sizePage.widthDesc', '设置组件的宽度 ({{value}} px),取值范围 {{min}} - {{max}} px。', {
value: width,
min: minWidth,
max: maxWidth,
})}
{t('sizePage.height', '高度 (Height)')}
{t('sizePage.heightDesc', '设置组件的高度 ({{value}} px),取值范围 {{min}} - {{max}} px。', {
value: height,
min: minHeight,
max: maxHeight,
})}
)
}
================================================
FILE: src/pages/tray/tray-page.tsx
================================================
import { AppApi, BrowserWindowApi, NotificationApi, WidgetApi } from '@widget-js/core'
import consola from 'consola'
import { LogOut, Plus, Power, RefreshCcw, Settings, Share2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { supabase } from '@/api/supabase'
import DeployedWidgetList from '@/components/manager/deployed-widget-list'
import { SocialLinks } from '@/components/tray/social-links'
import { TrayMenuItem } from '@/components/tray/tray-menu-item'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { TooltipProvider } from '@/components/ui/tooltip'
import UserAvatar from '@/components/user-avatar'
import { useAppRuntimeInfo } from '@/hooks/use-app-runtime-info'
import { useSupabaseChannel } from '@/hooks/use-supabase-channel'
import { useUser } from '@/hooks/use-user'
export default function TrayPage() {
const { t } = useTranslation()
const { simpleInfo } = useAppRuntimeInfo()
const [appVersion, setAppVersion] = useState()
const { loading, user, nickname, avatar } = useUser()
const [loginState, setLoginState] = useState('')
useEffect(() => {
AppApi.getVersion('app').then(setAppVersion)
const storedState = localStorage.getItem('wechat_login_state')
if (storedState) { setLoginState(storedState) }
}, [])
const shareApp = () => {
navigator.clipboard.writeText('https://widgetjs.cn')
NotificationApi.success(t('tray.downloadLinkCopied'))
}
const copyAndReport = () => {
const text = JSON.stringify(simpleInfo, null, 2)
navigator.clipboard.writeText(text)
NotificationApi.success(t('tray.infoCopied'))
BrowserWindowApi.openUrl('https://faq.widgetjs.cn', { external: true })
}
const restartWidgets = () => {
WidgetApi.restartWidgets()
}
// Handle Supabase channel for login
useSupabaseChannel(loginState ? `wechat-login-${loginState}` : '', async (payload: any) => {
consola.info(payload)
const currentSession = payload.payload.session
const loginRes = await supabase.auth.setSession(currentSession)
if (loginRes.error) {
NotificationApi.error(loginRes.error.message)
}
else {
AppApi.showAppWindow('/user/profile', {
width: 1200,
height: 800,
})
}
})
const loginPage = () => {
if (user) {
AppApi.showAppWindow('/user/profile', {
width: 1200,
height: 800,
})
}
else {
const newState = crypto.randomUUID().replace(/-/g, '')
setLoginState(newState)
localStorage.setItem('wechat_login_state', newState)
BrowserWindowApi.openUrl(`https://open.weixin.qq.com/connect/qrconnect?appid=wxf91b19da281f23a9&redirect_uri=https%3A%2F%2Fwidgetjs.cn%2Fapi%2Fv1%2Fuser%2Flogin%2Fwechat%2Fcallback&response_type=code&scope=snsapi_login&state=${newState}#wechat_redirect`, {
width: 800,
height: 600,
frame: true,
transparent: false,
titleBarStyle: 'default',
})
}
}
return (
{/* Header */}
{loading ? t('appInfo.loading') : nickname}
AppApi.openRuntimeInfoWindow()}>
{t('tray.appVersion')}
:
{appVersion}
{t('tray.systemVersion')}
:
{simpleInfo?.systemName?.replaceAll('Windows', 'Win')}
{/* Widgets List */}
{t('tray.runningWidgets')}
{/* Menus */}
AppApi.showAppWindow('/widget/search', { width: 1200, height: 800 })}
/>
AppApi.openSettingWindow()}
/>
AppApi.openCheckUpdateWindow()}
/>
{t('tray.restartWidgets')}
{t('tray.restartWidgetsConfirm')}
{t('tray.no')}
{t('tray.yes')}
AppApi.exit()}
/>
{/* Footer */}
)
}
================================================
FILE: src/pages/user/profile-page.tsx
================================================
import type { Widget } from '@widget-js/core'
import { NotificationApi, WidgetApi } from '@widget-js/core'
import consola from 'consola'
import { Check, Loader2, LogOut, Pencil } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { getStorageLink, supabase } from '@/api/supabase'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import UserAvatar from '@/components/user-avatar'
import WidgetSyncItem from '@/components/widget-sync-item'
import { useUser } from '@/hooks/use-user'
export default function ProfilePage() {
const [nicknameEdit, setNicknameEdit] = useState('')
const [avatarEdit, setAvatarEdit] = useState('')
const [widgets, setWidgets] = useState([])
const [signOutLoading, setSignOutLoading] = useState(false)
const [uploadLoading, setUploadLoading] = useState(false)
const { avatar, nickname, userId } = useUser((user) => {
if (user) {
setNicknameEdit(user.user_metadata?.nickname || '')
setAvatarEdit(user.user_metadata?.avatar || '')
}
})
// Sync state when user changes (e.g. initial load)
useEffect(() => {
if (nickname) { setNicknameEdit(nickname) }
if (avatar) { setAvatarEdit(avatar) }
}, [nickname, avatar])
useEffect(() => {
WidgetApi.getWidgets().then((arr) => {
const syncWidgets = arr.filter(it => it.synchronizable)
setWidgets(syncWidgets)
consola.info('Widgets:', syncWidgets)
})
}, [])
const pickImageAndUpload = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/jpg,image/jpeg,image/png,image/webp'
input.onchange = async (event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) { return }
setUploadLoading(true)
const loadingToast = toast.loading('上传中...')
try {
if (!userId) {
toast.error('用户未登录')
return
}
const fileExtension = file.name.split('.').pop()
const fileName = `avatar.${fileExtension}`
const { data, error } = await supabase.storage
.from('widget-avatar')
.upload(`${userId}/${fileName}`, file, {
cacheControl: '3600',
upsert: true,
})
if (error) {
toast.error(`上传失败: ${error.message}`)
}
else {
const fullUrl = getStorageLink(data?.fullPath)
const updateRes = await supabase.auth.updateUser({
data: { avatar: fullUrl },
})
if (updateRes.error) {
toast.error(`更新头像失败:${updateRes.error.message}`)
}
else {
setAvatarEdit(fullUrl)
toast.success('头像更新成功')
}
}
}
catch (err) {
toast.error('上传过程中发生错误')
consola.error(err)
}
finally {
setUploadLoading(false)
toast.dismiss(loadingToast)
}
}
input.click()
}
const signOut = async () => {
setSignOutLoading(true)
try {
const { error } = await supabase.auth.signOut()
if (error) {
toast.error(`退出登录失败: ${error.message}`)
}
else {
NotificationApi.success('退出登录成功')
window.close()
}
}
finally {
setSignOutLoading(false)
}
}
const saveNickName = async () => {
if (!nicknameEdit.trim()) {
toast.error('昵称不能为空')
return
}
const { error } = await supabase.auth.updateUser({
data: { nickname: nicknameEdit.trim() },
})
if (error) {
toast.error(`更新昵称失败:${error.message}`)
}
else {
toast.success('昵称更新成功')
}
}
return (
用户信息
{signOutLoading ? : }
确认退出登录?
退出登录后将无法使用同步功能和 AI 功能。
取消
确认退出
数据同步
{widgets.map(widget => (
))}
{widgets.length === 0 && (
暂无同步小组件
)}
)
}
================================================
FILE: src/router/index.tsx
================================================
import type { RouteObject } from 'react-router-dom'
import {
Bot,
Code2,
Globe,
Package,
Palette,
Plus,
Settings,
} from 'lucide-react'
import { createHashRouter, Navigate } from 'react-router-dom'
import { DashboardLayout } from '@/components/dashboard-layout'
import AddWidgetPage from '@/pages/add/add-widget-page'
import AiPage from '@/pages/ai/ai-page'
import DevPage from '@/pages/dev/dev-page'
import ErrorPage from '@/pages/error-page'
import WidgetPackageManagerPage from '@/pages/packages/widget-package-manager-page'
import AppInfoPage from '@/pages/settings/app-info-page'
import GeneralPage from '@/pages/settings/general-page'
import ProxyPage from '@/pages/settings/proxy-page'
import ThemePage from '@/pages/settings/theme-page'
import SizePage from '@/pages/size/size-page'
import TrayPage from '@/pages/tray/tray-page'
import ProfilePage from '@/pages/user/profile-page'
export const routes: RouteObject[] = [
{
path: '/',
element: ,
errorElement: ,
children: [
{
index: true,
element: ,
},
{
path: 'widget',
handle: {
title: 'sidebar.widgetManagement',
sidebarGroup: true,
},
children: [
{
index: true,
path: 'search',
element: ,
handle: {
title: 'sidebar.addWidget',
icon: Plus,
sidebarMenu: true,
},
},
{
path: 'package',
element: ,
handle: {
title: 'sidebar.packageManagement',
icon: Package,
sidebarMenu: true,
},
},
{
path: 'dev',
element: ,
handle: {
title: 'sidebar.dev',
icon: Code2,
sidebarMenu: true,
},
},
],
},
{
path: 'setting',
handle: {
title: 'sidebar.generalSettings',
sidebarGroup: true,
},
children: [
{
path: 'common',
element: ,
handle: {
title: 'sidebar.generalSettings',
icon: Settings,
sidebarMenu: true,
},
},
{
path: 'theme',
element: ,
handle: {
title: 'sidebar.globalTheme',
icon: Palette,
sidebarMenu: true,
},
},
{
path: 'ai',
element: ,
handle: {
title: 'sidebar.ai',
icon: Bot,
sidebarMenu: true,
},
},
{
path: 'proxy',
element: ,
handle: {
title: 'sidebar.proxySettings',
icon: Globe,
group: 'settings',
sidebarMenu: true,
},
},
{
path: 'info',
element: ,
handle: {
title: 'appInfo.title',
},
},
],
},
{
path: 'user/profile',
element: ,
handle: { title: 'user.account' },
},
],
},
{
path: '/tray/menu',
element: ,
},
{
path: '/size',
element: ,
},
{
path: '*',
element: ,
},
]
export const router = createHashRouter(routes)
================================================
FILE: src/utils/version-utils.ts
================================================
import type { AppVersion } from '@/model/app-version'
import { AppApi } from '@widget-js/core'
import axios from 'axios'
import consola from 'consola'
import semver from 'semver'
export default class VersionUtils {
static checkNewVersion(onNewVersion: (version: AppVersion) => void, onError?: (error: any) => void, onFinally?: () => void) {
axios.get('https://widget-fun.oss-cn-hangzhou.aliyuncs.com/version/version.json')
.then(async (response) => {
// handle success
if (response.status == 200) {
const data = response.data as AppVersion
const currentVersion = await AppApi.getVersion()
consola.info('current:', currentVersion, 'server:', data.version)
if (semver.gt(data.version, currentVersion)) {
consola.info('New version detected:', data.version)
consola.info('Download Link:', data.downloadLink)
onNewVersion(data)
}
}
})
.catch((error) => {
onError?.(error)
})
.finally(() => {
onFinally?.()
})
}
}
================================================
FILE: src/utils/widget-util.ts
================================================
import type { SocialType } from '@widget-js/core'
export default class WidgetUtil {
static getSocialLinkIcon(socialName: SocialType | string) {
switch (socialName) {
case 'github':
return 'https://widgetjs.cn/image/logo/github.png'
case 'bilibili':
return 'https://widgetjs.cn/image/logo/bilibili.png'
case 'discord':
return 'https://widgetjs.cn/image/logo/discord.png'
case 'tiktok':
case 'douyin':
return 'https://widgetjs.cn/image/logo/douyin.png'
case 'email':
return 'https://widgetjs.cn/image/logo/email.png'
case 'qq':
return 'https://widgetjs.cn/image/logo/qq.png'
case 'gitee':
return 'https://widgetjs.cn/image/logo/gitee.png'
case 'youtube':
return 'https://widgetjs.cn/image/logo/youtube.png'
case 'wechat':
return 'https://widgetjs.cn/image/logo/wechat.png'
default:
return ''
}
}
}
================================================
FILE: tsconfig.app.json
================================================
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
================================================
FILE: tsconfig.json
================================================
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
================================================
FILE: tsconfig.node.json
================================================
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
================================================
FILE: vite.config.ts
================================================
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig((_config) => {
return {
base: './',
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 8085,
},
}
})