mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-03 06:29:56 +08:00
Initial commit
This commit is contained in:
215
web/src/contexts/AppWindowsContext.tsx
Normal file
215
web/src/contexts/AppWindowsContext.tsx
Normal 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;
|
||||
}
|
||||
66
web/src/contexts/AuthContext.tsx
Normal file
66
web/src/contexts/AuthContext.tsx
Normal 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);
|
||||
}
|
||||
12
web/src/contexts/SystemContext.tsx
Normal file
12
web/src/contexts/SystemContext.tsx
Normal 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;
|
||||
};
|
||||
202
web/src/contexts/ThemeContext.tsx
Normal file
202
web/src/contexts/ThemeContext.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user