Initial commit

This commit is contained in:
shiyu
2026-02-09 13:19:28 +08:00
commit 17d13999b0
300 changed files with 48565 additions and 0 deletions

View File

@@ -0,0 +1,215 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { Modal, Checkbox } from 'antd';
import type { VfsEntry } from '../api/client';
import type { AppDescriptor } from '../apps/registry';
import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../apps/registry';
import { useI18n } from '../i18n';
import { useNavigate } from 'react-router';
type WindowBase = {
id: string;
app: AppDescriptor;
maximized: boolean;
minimized: boolean;
x: number;
y: number;
width: number;
height: number;
};
export type AppWindowItem =
| (WindowBase & {
kind: 'file';
entry: VfsEntry;
filePath: string;
})
| (WindowBase & {
kind: 'app';
});
type AppWindowPatch = Partial<Pick<AppWindowItem, 'maximized' | 'minimized' | 'x' | 'y' | 'width' | 'height'>>;
interface AppWindowsContextValue {
windows: AppWindowItem[];
openWithApp: (entry: VfsEntry, app: AppDescriptor, currentPath: string) => void;
openFileWithDefaultApp: (entry: VfsEntry, currentPath: string) => void;
confirmOpenWithApp: (entry: VfsEntry, appKey: string, currentPath: string) => void;
openApp: (app: AppDescriptor) => void;
closeWindow: (id: string) => void;
toggleMax: (id: string) => void;
bringToFront: (id: string) => void;
updateWindow: (id: string, patch: AppWindowPatch) => void;
minimizeWindow: (id: string) => void;
restoreWindow: (id: string) => void;
toggleMinimize: (id: string) => void;
}
const AppWindowsContext = createContext<AppWindowsContextValue | null>(null);
export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { t } = useI18n();
const navigate = useNavigate();
const [windows, setWindows] = useState<AppWindowItem[]>([]);
const openWithApp = useCallback((entry: VfsEntry, app: AppDescriptor, currentPath: string) => {
const fullPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
setWindows(ws => {
const idx = ws.length;
const bounds = app.defaultBounds || {};
const baseX = bounds.x ?? (160 + idx * 32);
const baseY = bounds.y ?? (100 + idx * 28);
const baseW = bounds.width ?? 640;
const baseH = bounds.height ?? 480;
const vw = window.innerWidth;
const vh = window.innerHeight;
const finalW = Math.min(baseW, vw - 40);
const finalH = Math.min(baseH, vh - 60);
const finalX = Math.min(Math.max(0, baseX), vw - finalW - 8);
const finalY = Math.min(Math.max(0, baseY), vh - finalH - 8);
return [
...ws,
{
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
kind: 'file',
app,
entry,
filePath: fullPath,
maximized: !!app.defaultMaximized,
minimized: false,
x: finalX,
y: finalY,
width: finalW,
height: finalH,
},
];
});
}, []);
const openApp = useCallback((app: AppDescriptor) => {
if (!app.openAppComponent) {
return;
}
setWindows(ws => {
const idx = ws.length;
const bounds = app.defaultBounds || {};
const baseX = bounds.x ?? (160 + idx * 32);
const baseY = bounds.y ?? (100 + idx * 28);
const baseW = bounds.width ?? 640;
const baseH = bounds.height ?? 480;
const vw = window.innerWidth;
const vh = window.innerHeight;
const finalW = Math.min(baseW, vw - 40);
const finalH = Math.min(baseH, vh - 60);
const finalX = Math.min(Math.max(0, baseX), vw - finalW - 8);
const finalY = Math.min(Math.max(0, baseY), vh - finalH - 8);
return [
...ws,
{
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
kind: 'app',
app,
maximized: !!app.defaultMaximized,
minimized: false,
x: finalX,
y: finalY,
width: finalW,
height: finalH,
},
];
});
}, []);
const openFileWithDefaultApp = useCallback((entry: VfsEntry, currentPath: string) => {
const apps = getAppsForEntry(entry);
if (!apps.length) {
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
const extQuery = ext ? `.${ext}` : entry.name;
Modal.confirm({
title: t('Cannot open file'),
content: t('No app available for this file. Go to App Store to search {ext}?', { ext: extQuery }),
okText: t('Go to App Store'),
cancelText: t('Cancel'),
onOk: () => {
const params = new URLSearchParams();
params.set('tab', 'discover');
if (extQuery) {
params.set('query', extQuery);
}
navigate(`/plugins?${params.toString()}`);
},
});
return;
}
const defaultApp = getDefaultAppForEntry(entry) || apps[0];
openWithApp(entry, defaultApp, currentPath);
}, [navigate, openWithApp, t]);
const confirmOpenWithApp = useCallback((entry: VfsEntry, appKey: string, currentPath: string) => {
const app = getAppByKey(appKey);
if (!app) {
Modal.error({ title: t('Error'), content: t('App "{key}" not found.', { key: appKey }) });
return;
}
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
let setDefault = false;
Modal.confirm({
title: t('Open with {app}', { app: app.name }),
content: (
<div>
<div style={{ marginBottom: 8 }}>{t('File')}: {entry.name}</div>
<Checkbox onChange={e => (setDefault = e.target.checked)}>
{t('Set as default for .{ext}', { ext })}
</Checkbox>
</div>
),
onOk: () => {
if (setDefault && ext) {
localStorage.setItem(`app.default.${ext}`, app.key);
}
openWithApp(entry, app, currentPath);
},
});
}, [openWithApp, t]);
const closeWindow = (id: string) => setWindows(ws => ws.filter(w => w.id !== id));
const toggleMax = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, maximized: !w.maximized } : w)));
const bringToFront = (id: string) => setWindows(ws => {
const target = ws.find(w => w.id === id);
if (!target) return ws;
return [...ws.filter(w => w.id !== id), target];
});
const updateWindow = (id: string, patch: AppWindowPatch) =>
setWindows(ws => ws.map(w => (w.id === id ? { ...w, ...patch } : w)));
const minimizeWindow = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: true } : w)));
const restoreWindow = (id: string) => setWindows(ws => {
const target = ws.find(w => w.id === id);
if (!target) return ws;
const restored = { ...target, minimized: false };
return [...ws.filter(w => w.id !== id), restored];
});
const toggleMinimize = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: !w.minimized } : w)));
const value = useMemo<AppWindowsContextValue>(() => ({
windows,
openWithApp,
openFileWithDefaultApp,
confirmOpenWithApp,
openApp,
closeWindow,
toggleMax,
bringToFront,
updateWindow,
minimizeWindow,
restoreWindow,
toggleMinimize,
}), [windows, openWithApp, openFileWithDefaultApp, confirmOpenWithApp, openApp]);
return <AppWindowsContext.Provider value={value}>{children}</AppWindowsContext.Provider>;
};
export function useAppWindows() {
const ctx = useContext(AppWindowsContext);
if (!ctx) throw new Error('useAppWindows must be used within AppWindowsProvider');
return ctx;
}

View File

@@ -0,0 +1,66 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { authApi, type MeResponse } from '../api/auth';
interface AuthContextType {
token: string | null;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
register: (username: string, password: string, email: string, full_name?: string) => Promise<void>;
user: MeResponse | null;
refreshUser: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({} as any);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'));
const [user, setUser] = useState<MeResponse | null>(null);
const isAuthenticated = !!token;
useEffect(() => {
if (token) localStorage.setItem('token', token);
else localStorage.removeItem('token');
}, [token]);
const login = async (username: string, password: string) => {
const res = await authApi.login({ username, password });
if (res) {
setToken(res.access_token);
try { await refreshUser(); } catch { void 0; }
}
};
const logout = () => {
setToken(null);
setUser(null);
};
const register = async (username: string, password: string, email: string, full_name?: string) => {
await authApi.register(username, password, email, full_name);
};
const refreshUser = async () => {
if (!localStorage.getItem('token')) { setUser(null); return; }
const me = await authApi.me();
setUser(me);
};
useEffect(() => {
if (token) {
refreshUser().catch(() => setUser(null));
} else {
setUser(null);
}
}, [token]);
return (
<AuthContext.Provider value={{ token, isAuthenticated, login, logout, register, user, refreshUser }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

View File

@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
import type { SystemStatus } from '../api/config';
export const SystemContext = createContext<SystemStatus | null>(null);
export const useSystemStatus = () => {
const context = useContext(SystemContext);
if (context === undefined) {
throw new Error('useSystemStatus must be used within a SystemProvider');
}
return context;
};

View File

@@ -0,0 +1,202 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { ConfigProvider, theme as antdTheme } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import enUS from 'antd/locale/en_US';
import type { ThemeConfig } from 'antd/es/config-provider/context';
import { getPublicConfig } from '../api/config';
import { useAuth } from './AuthContext';
import baseTheme from '../theme';
import { useI18n } from '../i18n';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeState {
mode: ThemeMode;
primaryColor?: string | null;
borderRadius?: number | null;
customTokens?: Record<string, any> | null;
customCSS?: string | null;
}
interface ThemeContextType {
refreshTheme: () => Promise<void>;
previewTheme: (patch: Partial<ThemeState>) => void;
mode: ThemeMode;
resolvedMode: ThemeMode;
}
const Ctx = createContext<ThemeContextType>({} as any);
const CONFIG_KEYS = {
MODE: 'THEME_MODE',
PRIMARY: 'THEME_PRIMARY_COLOR',
RADIUS: 'THEME_BORDER_RADIUS',
TOKENS: 'THEME_CUSTOM_TOKENS',
CSS: 'THEME_CUSTOM_CSS',
};
function parseJSON<T = any>(text: string | null | undefined): T | null {
if (!text) return null;
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
function useSystemDarkPreferred() {
const [isDark, setIsDark] = useState<boolean>(
typeof window !== 'undefined' && window.matchMedia
? window.matchMedia('(prefers-color-scheme: dark)').matches
: false
);
useEffect(() => {
if (!window.matchMedia) return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => setIsDark(e.matches);
mql.addEventListener?.('change', handler);
return () => mql.removeEventListener?.('change', handler);
}, []);
return isDark;
}
function buildThemeConfig(state: ThemeState, systemDark: boolean): ThemeConfig {
const resolvedMode: ThemeMode = state.mode === 'system' ? (systemDark ? 'dark' : 'light') : state.mode;
const algorithm = resolvedMode === 'dark'
? [antdTheme.darkAlgorithm, antdTheme.compactAlgorithm]
: [antdTheme.defaultAlgorithm, antdTheme.compactAlgorithm];
const safeBaseTokens: Record<string, any> = resolvedMode === 'dark'
? {
borderRadius: baseTheme.token?.borderRadius,
fontSize: baseTheme.token?.fontSize,
controlHeight: baseTheme.token?.controlHeight,
boxShadow: baseTheme.token?.boxShadow,
}
: { ...(baseTheme.token as any) };
const token = {
...safeBaseTokens,
...(state.primaryColor ? { colorPrimary: state.primaryColor } : {}),
...(state.borderRadius != null ? { borderRadius: state.borderRadius } : {}),
...(state.customTokens || {}),
} as any;
const baseComponents = { ...(baseTheme.components as any) };
if (resolvedMode === 'dark' && baseComponents) {
if (baseComponents.Menu) {
const rest = { ...baseComponents.Menu };
delete rest.itemHoverColor;
delete rest.itemHoverBg;
delete rest.itemSelectedBg;
delete rest.itemSelectedColor;
baseComponents.Menu = rest;
}
if (baseComponents.Dropdown) {
const rest = { ...baseComponents.Dropdown };
delete rest.controlItemBgHover;
baseComponents.Dropdown = rest;
}
if (baseComponents.Table) {
const rest = { ...baseComponents.Table };
delete rest.headerBg;
delete rest.rowHoverBg;
baseComponents.Table = rest;
}
}
return { algorithm, token, components: baseComponents } satisfies ThemeConfig;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const { isAuthenticated } = useAuth();
const { lang } = useI18n();
const systemDark = useSystemDarkPreferred();
const [state, setState] = useState<ThemeState>({ mode: 'light' });
const stateRef = useRef(state);
const styleTagRef = useRef<HTMLStyleElement | null>(null);
useEffect(() => {
stateRef.current = state;
}, [state]);
const ensureStyleTag = useCallback(() => {
if (styleTagRef.current) return styleTagRef.current;
let styleEl = document.getElementById('foxel-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'foxel-custom-css';
document.head.appendChild(styleEl);
}
styleTagRef.current = styleEl;
return styleEl;
}, []);
const applyCustomCSS = useCallback((cssText: string | null | undefined) => {
const el = ensureStyleTag();
el.textContent = cssText || '';
}, [ensureStyleTag]);
const applyHtmlDataTheme = useCallback((mode: ThemeMode) => {
const finalMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
document.documentElement.setAttribute('data-theme', finalMode);
}, [systemDark]);
const refreshTheme = useCallback(async () => {
if (!isAuthenticated) {
applyHtmlDataTheme(stateRef.current.mode || 'light');
applyCustomCSS(stateRef.current.customCSS || '');
return;
}
try {
const cfg = await getPublicConfig();
const mode = (cfg[CONFIG_KEYS.MODE] as ThemeMode) || 'light';
const primary = (cfg[CONFIG_KEYS.PRIMARY] as string) || null;
const radiusStr = cfg[CONFIG_KEYS.RADIUS];
const radius = radiusStr != null ? Number(radiusStr) : null;
const customTokens = parseJSON<Record<string, any>>(cfg[CONFIG_KEYS.TOKENS]);
const customCSS = (cfg[CONFIG_KEYS.CSS] as string) || '';
setState({ mode, primaryColor: primary, borderRadius: radius, customTokens, customCSS });
applyHtmlDataTheme(mode);
applyCustomCSS(customCSS);
} catch {
applyHtmlDataTheme('light');
applyCustomCSS('');
}
}, [applyCustomCSS, applyHtmlDataTheme, isAuthenticated]);
const previewTheme = useCallback((patch: Partial<ThemeState>) => {
const next: ThemeState = { ...stateRef.current, ...patch };
stateRef.current = next;
setState(next);
applyHtmlDataTheme(next.mode || 'light');
applyCustomCSS(next.customCSS || '');
}, [applyCustomCSS, applyHtmlDataTheme]);
useEffect(() => {
void refreshTheme();
}, [refreshTheme]);
const themeConfig = useMemo(() => buildThemeConfig(state, systemDark), [state, systemDark]);
const resolvedMode: ThemeMode = useMemo(() => (state.mode === 'system' ? (systemDark ? 'dark' : 'light') : state.mode), [state.mode, systemDark]);
const locale = useMemo(() => (lang === 'zh' ? zhCN : enUS), [lang]);
const ctxValue = useMemo<ThemeContextType>(() => ({
refreshTheme,
previewTheme,
mode: state.mode,
resolvedMode,
}), [previewTheme, refreshTheme, resolvedMode, state.mode]);
return (
<Ctx.Provider value={ctxValue}>
<ConfigProvider theme={{ ...themeConfig, cssVar: {} }} locale={locale}>
{children}
</ConfigProvider>
</Ctx.Provider>
);
}
export function useTheme() {
return useContext(Ctx);
}