mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
[重构] 前端改造v2
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
type InlineMessageTone = 'info' | 'success' | 'danger';
|
||||
|
||||
const toneClasses: Record<InlineMessageTone, string> = {
|
||||
info: 'border-[var(--status-info-border)] bg-[var(--status-info-soft)] text-[var(--status-info-foreground)]',
|
||||
success:
|
||||
'border-[var(--status-success-border)] bg-[var(--status-success-soft)] text-[var(--status-success-foreground)]',
|
||||
danger:
|
||||
'border-[var(--status-danger-border)] bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]',
|
||||
};
|
||||
|
||||
interface InlineMessageProps {
|
||||
tone?: InlineMessageTone;
|
||||
message: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InlineMessage({
|
||||
tone = 'info',
|
||||
message,
|
||||
className,
|
||||
}: InlineMessageProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border px-4 py-3 text-sm leading-6',
|
||||
toneClasses[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useRef } from 'react';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (
|
||||
container: string | HTMLElement,
|
||||
options: {
|
||||
sitekey: string;
|
||||
callback: (token: string) => void;
|
||||
'expired-callback'?: () => void;
|
||||
'error-callback'?: () => void;
|
||||
theme?: 'auto' | 'light' | 'dark';
|
||||
},
|
||||
) => string;
|
||||
remove: (widgetId: string) => void;
|
||||
reset: (widgetId?: string) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const TURNSTILE_SCRIPT_ID = 'cloudflare-turnstile-script';
|
||||
const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
|
||||
interface TurnstileWidgetProps {
|
||||
siteKey: string;
|
||||
onVerify: (token: string) => void;
|
||||
onExpire?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
export function TurnstileWidget({
|
||||
siteKey,
|
||||
onVerify,
|
||||
onExpire,
|
||||
onError,
|
||||
}: TurnstileWidgetProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const widgetIdRef = useRef<string | null>(null);
|
||||
const elementId = useId().replace(/:/g, '-');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const mountWidget = () => {
|
||||
if (cancelled || !containerRef.current || !window.turnstile) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (widgetIdRef.current) {
|
||||
window.turnstile.remove(widgetIdRef.current);
|
||||
widgetIdRef.current = null;
|
||||
}
|
||||
|
||||
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
callback: onVerify,
|
||||
'expired-callback': onExpire,
|
||||
'error-callback': onError,
|
||||
theme: 'auto',
|
||||
});
|
||||
};
|
||||
|
||||
const existingScript = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
|
||||
|
||||
if (window.turnstile) {
|
||||
mountWidget();
|
||||
} else if (existingScript) {
|
||||
existingScript.addEventListener('load', mountWidget);
|
||||
} else {
|
||||
const script = document.createElement('script');
|
||||
script.id = TURNSTILE_SCRIPT_ID;
|
||||
script.src = TURNSTILE_SCRIPT_SRC;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.addEventListener('load', mountWidget);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const script = document.getElementById(TURNSTILE_SCRIPT_ID) as HTMLScriptElement | null;
|
||||
if (script) {
|
||||
script.removeEventListener('load', mountWidget);
|
||||
}
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.remove(widgetIdRef.current);
|
||||
widgetIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [onError, onExpire, onVerify, siteKey]);
|
||||
|
||||
return <div id={elementId} ref={containerRef} className='min-h-16' />;
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import { getCurrentNavigationItem } from '@/lib/utils/navigation';
|
||||
import { publicEnv } from '@/lib/env/public-env';
|
||||
@@ -7,10 +11,19 @@ import { useAppShellStore } from '@/store/app-shell';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function DashboardTopbar() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const currentPath = pathname ?? '/';
|
||||
const { logout, user } = useAuth();
|
||||
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
|
||||
const currentItem = getCurrentNavigationItem(currentPath);
|
||||
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setIsLoggingOut(true);
|
||||
await logout();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className='sticky top-0 z-10 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/75 px-4 py-4 backdrop-blur md:px-8'>
|
||||
@@ -33,6 +46,11 @@ export function DashboardTopbar() {
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
|
||||
{user ? (
|
||||
<span className='hidden rounded-full border border-[var(--border-default)] px-3 py-1.5 md:inline-flex'>
|
||||
{user.display_name || user.username}
|
||||
</span>
|
||||
) : null}
|
||||
<ThemeToggle />
|
||||
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
||||
静态导出模式
|
||||
@@ -40,6 +58,14 @@ export function DashboardTopbar() {
|
||||
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
|
||||
版本 {publicEnv.appVersion}
|
||||
</span>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => void handleLogout()}
|
||||
disabled={isLoggingOut}
|
||||
className='inline-flex rounded-full border border-[var(--border-default)] bg-[var(--control-background)] px-3 py-1.5 text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)] disabled:cursor-not-allowed disabled:opacity-60'
|
||||
>
|
||||
{isLoggingOut ? '退出中...' : '退出登录'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { AuthProvider } from '@/components/providers/auth-provider';
|
||||
import { ThemeProvider } from '@/components/providers/theme-provider';
|
||||
|
||||
interface AppProvidersProps {
|
||||
@@ -27,8 +28,10 @@ export function AppProviders({ children }: AppProvidersProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { logout as logoutRequest, getCurrentUser } from '@/features/auth/api/auth';
|
||||
import type { AuthUser } from '@/types/auth';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
refreshUser: () => Promise<AuthUser | null>;
|
||||
setUser: (user: AuthUser | null) => void;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUserState] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
try {
|
||||
const nextUser = await getCurrentUser();
|
||||
setUserState(nextUser);
|
||||
return nextUser;
|
||||
} catch {
|
||||
setUserState(null);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshUser();
|
||||
}, [refreshUser]);
|
||||
|
||||
const setUser = useCallback((nextUser: AuthUser | null) => {
|
||||
setUserState(nextUser);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await logoutRequest();
|
||||
} finally {
|
||||
setUserState(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: user !== null,
|
||||
refreshUser,
|
||||
setUser,
|
||||
logout,
|
||||
}),
|
||||
[isLoading, logout, refreshUser, setUser, user],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user