初始提交:姜十三论坛 Jiang13 Forum
轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
47
frontend/src/hooks/useAuth.tsx
Normal file
47
frontend/src/hooks/useAuth.tsx
Normal 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);
|
||||
57
frontend/src/hooks/useGlobalWheelScroll.ts
Normal file
57
frontend/src/hooks/useGlobalWheelScroll.ts
Normal 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]);
|
||||
}
|
||||
35
frontend/src/hooks/useTheme.tsx
Normal file
35
frontend/src/hooks/useTheme.tsx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user