初始提交:姜十三论坛 Jiang13 Forum

轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-15 21:08:52 +08:00
commit e1c1708715
140 changed files with 16115 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
import { api } from '../api/client';
import type { User } from '../api/types';
interface AuthCtx {
user: User | null;
loading: boolean;
refresh: () => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthCtx>({
user: null, loading: true,
refresh: async () => {}, logout: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const data = await api.me();
setUser(data.user ?? null);
} catch {
setUser(null);
} finally {
setLoading(false);
}
}, []);
// 初始化只拉一次用户信息
useEffect(() => { refresh(); }, [refresh]);
const logout = async () => {
await api.logout();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, refresh, logout }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);

View File

@@ -0,0 +1,57 @@
import { useEffect, type RefObject } from 'react';
/** 判断元素是否可在指定方向继续滚动 */
function canElementScroll(el: HTMLElement, deltaY: number): boolean {
const { overflowY } = getComputedStyle(el);
if (overflowY !== 'auto' && overflowY !== 'scroll' && overflowY !== 'overlay') {
return false;
}
if (el.scrollHeight <= el.clientHeight + 1) return false;
if (deltaY < 0) return el.scrollTop > 0;
return el.scrollTop + el.clientHeight < el.scrollHeight - 1;
}
/** 从目标节点向上查找首个可滚动容器 */
function findScrollable(start: HTMLElement | null, deltaY: number, boundary: HTMLElement): HTMLElement | null {
let el = start;
while (el && el !== boundary) {
if (canElementScroll(el, deltaY)) return el;
el = el.parentElement;
}
return null;
}
/**
* 文章页:鼠标在页面任意位置滚轮时,统一滚动主内容区。
* 保留 textarea、表情面板等主内容区内部可滚动元素的独立滚动。
*/
export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, enabled = true) {
useEffect(() => {
if (!enabled) return;
const scrollEl = scrollRef.current;
if (!scrollEl) return;
const root = scrollEl.closest('.app-shell') as HTMLElement | null;
if (!root) return;
const onWheel = (e: WheelEvent) => {
const target = e.target instanceof HTMLElement ? e.target : null;
if (!target) return;
const inner = findScrollable(target, e.deltaY, root);
// 主内容区内部嵌套滚动(如 textarea、表情面板保留原生行为
if (inner && inner !== scrollEl && scrollEl.contains(inner)) return;
// 鼠标已在主滚动容器上时,交给浏览器原生处理
if (inner === scrollEl) return;
const prev = scrollEl.scrollTop;
scrollEl.scrollTop += e.deltaY;
if (scrollEl.scrollTop !== prev) {
e.preventDefault();
}
};
root.addEventListener('wheel', onWheel, { passive: false });
return () => root.removeEventListener('wheel', onWheel);
}, [scrollRef, enabled]);
}

View File

@@ -0,0 +1,35 @@
import { createContext, useContext, useLayoutEffect, useState, ReactNode } from 'react';
import { applyTheme, getStoredTheme, type Theme } from '../utils/theme';
const ThemeContext = createContext<{ theme: Theme; toggle: () => void }>({
theme: 'light', toggle: () => {},
});
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(getStoredTheme);
useLayoutEffect(() => {
applyTheme(theme);
}, [theme]);
const toggle = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
export function useMediaQuery(query: string) {
const [match, setMatch] = useState(() => window.matchMedia(query).matches);
useLayoutEffect(() => {
const m = window.matchMedia(query);
const fn = () => setMatch(m.matches);
m.addEventListener('change', fn);
return () => m.removeEventListener('change', fn);
}, [query]);
return match;
}