mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
454542c1d0
- README 默认英文:README.en.md → README.md(英文为默认),中文移至 README.zh-CN.md,语言切换链接同步 - 恢复被删除的 docs/en/ 英文文档(git 历史 cc5e53c5^),删除 4 篇已废弃文件 - 英文导航 config.ts 对齐中文结构(新增 Deployment/Changelog 侧栏,同步 Guide/Design 条目) - 翻译 15 篇中文新增文档:guide 5 篇(certificates/pages-usage/proxy-config/uptime-kuma/zone-domain-migration)+ design 10 篇(zone-design/cloudflare-pointing/waf-orchestration/origin-error-page/edge-cache-design/pages-design/logstore/kuma-design/login-captcha/observability 三篇) - en 首页更新(新增 Pages 特性、tagline 同步);changelog 英文入口指向中文版 - vitepress 构建验证:43 个英文页面全部渲染 注意:29 篇旧英文文档为恢复版,部分内容(如 deployment/server、reference/configuration)可能落后于中文,需后续逐篇同步
294 lines
9.5 KiB
TypeScript
294 lines
9.5 KiB
TypeScript
'use client';
|
|
|
|
import { memo, useEffect, useState } from 'react';
|
|
import { AnimatePresence, motion } from 'motion/react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Bell,
|
|
Info,
|
|
Maximize2,
|
|
Minimize2,
|
|
Moon,
|
|
Search,
|
|
Settings,
|
|
Sun,
|
|
} from 'lucide-react';
|
|
import { useUser } from '@/contexts/user-context';
|
|
import { useBellRing } from '@/contexts/bell-ring-context';
|
|
import { useNotificationSettings } from '@/contexts/notification-settings-context';
|
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
|
import { useTheme } from 'next-themes';
|
|
import { useRouter } from 'next/navigation';
|
|
import { SearchDialog } from '@/components/layout/search-dialog';
|
|
import { Kbd } from '@/components/ui/kbd';
|
|
import { LanguageSwitcher } from '@/components/common/language-switcher';
|
|
|
|
/**
|
|
* 铃铛按钮组件
|
|
*/
|
|
const BellButton = memo(function BellButton() {
|
|
const { isRinging } = useBellRing();
|
|
const { showBell, isMounted } = useNotificationSettings();
|
|
const t = useTranslations('layout.header');
|
|
const [isClickAnimating, setIsClickAnimating] = useState(false);
|
|
|
|
if (!isMounted) return null;
|
|
if (!showBell) return null;
|
|
|
|
const handleClick = () => {
|
|
setIsClickAnimating(true);
|
|
setTimeout(() => setIsClickAnimating(false), 600);
|
|
};
|
|
|
|
return (
|
|
<Button
|
|
variant='ghost'
|
|
size='icon'
|
|
className='size-9 text-muted-foreground hover:text-foreground'
|
|
onClick={handleClick}
|
|
>
|
|
<Bell
|
|
className='size-[18px]'
|
|
style={
|
|
isRinging || isClickAnimating
|
|
? {
|
|
animation: 'var(--animate-bell-ring)',
|
|
transformOrigin: 'top center',
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
<span className='sr-only'>{t('notifications')}</span>
|
|
</Button>
|
|
);
|
|
});
|
|
|
|
/**
|
|
* 站点头部组件
|
|
* 用于显示站点头部
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <SiteHeader />
|
|
* ```
|
|
*/
|
|
export function SiteHeader({
|
|
isFullWidth = false,
|
|
onToggleFullWidth,
|
|
}: {
|
|
isFullWidth?: boolean;
|
|
onToggleFullWidth?: (value: boolean) => void;
|
|
}) {
|
|
const { user } = useUser();
|
|
const { setTheme, resolvedTheme } = useTheme();
|
|
const router = useRouter();
|
|
const t = useTranslations('layout.header');
|
|
const [mounted, setMounted] = useState(false);
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
|
|
const [metaKey, setMetaKey] = useState('⌘');
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
if (
|
|
typeof navigator !== 'undefined' &&
|
|
!navigator.userAgent?.includes('Mac')
|
|
) {
|
|
setMetaKey('Ctrl');
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div className='flex flex-col w-full'>
|
|
<AppBanner />
|
|
{user?.need_change_password && (
|
|
<div className='bg-amber-500/10 border-b border-amber-500/20 px-4 py-3 text-amber-500 text-xs sm:text-sm flex items-center justify-between gap-4'>
|
|
<div className='flex items-center gap-2'>
|
|
<Info className='size-4 shrink-0 text-amber-500' />
|
|
<span>{t('changePasswordBanner')}</span>
|
|
</div>
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
className='text-amber-500 hover:text-amber-600 border-amber-500/30 hover:border-amber-500/50 bg-transparent shrink-0 h-8 px-3 text-xs'
|
|
onClick={() => router.push('/settings/profile')}
|
|
>
|
|
{t('changePasswordAction')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<header className='flex h-(--header-height) shrink-0 items-center bg-background px-4 md:px-0'>
|
|
<div className='flex w-full items-center justify-between md:hidden'>
|
|
<div className='flex items-center gap-2'>
|
|
<SidebarTrigger />
|
|
<span className='text-sm font-medium truncate max-w-[120px]'>
|
|
{user?.nickname || user?.username || t('guest')}
|
|
</span>
|
|
</div>
|
|
<div className='flex items-center gap-1'>
|
|
<Button
|
|
variant='ghost'
|
|
size='icon'
|
|
className='size-9 text-muted-foreground hover:text-foreground'
|
|
onClick={() => setSearchOpen(true)}
|
|
>
|
|
<Search className='size-[18px]' />
|
|
<span className='sr-only'>{t('search')}</span>
|
|
</Button>
|
|
<BellButton />
|
|
<LanguageSwitcher />
|
|
<Button
|
|
variant='ghost'
|
|
size='icon'
|
|
className='size-9 text-muted-foreground hover:text-foreground'
|
|
onClick={() => router.push('/settings')}
|
|
>
|
|
<Settings className='size-[18px]' />
|
|
<span className='sr-only'>{t('settings')}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={`hidden md:flex w-full items-center gap-4 transition-all duration-300 ease-in-out ${!isFullWidth ? 'max-w-[1320px]' : 'max-w-full'} mx-auto md:px-8 lg:px-12`}
|
|
>
|
|
<div
|
|
className='relative w-64 cursor-pointer'
|
|
onClick={() => setSearchOpen(true)}
|
|
>
|
|
<Search className='absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
|
|
<div className='flex h-8 items-center rounded-md border border-border/60 bg-muted/70 pl-10 pr-2.5 text-sm text-muted-foreground transition-colors hover:border-border hover:bg-muted'>
|
|
<span>{t('search')}</span>
|
|
<Kbd className='ml-auto gap-0.5 font-mono'>
|
|
<span>{metaKey}</span>
|
|
<span>K</span>
|
|
</Kbd>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='ml-auto flex items-center gap-1'>
|
|
<BellButton />
|
|
<LanguageSwitcher />
|
|
<Button
|
|
variant='ghost'
|
|
size='icon'
|
|
className='size-9 text-muted-foreground hover:text-foreground'
|
|
onClick={() => router.push('/settings')}
|
|
>
|
|
<Settings className='size-[18px]' />
|
|
<span className='sr-only'>{t('settings')}</span>
|
|
</Button>
|
|
|
|
<Button
|
|
variant='ghost'
|
|
size='icon'
|
|
className='size-9 text-muted-foreground hover:text-foreground'
|
|
onClick={() => onToggleFullWidth?.(!isFullWidth)}
|
|
>
|
|
{isFullWidth ? (
|
|
<Minimize2 className='size-[18px]' />
|
|
) : (
|
|
<Maximize2 className='size-[18px]' />
|
|
)}
|
|
<span className='sr-only'>{t('toggleFullWidth')}</span>
|
|
</Button>
|
|
|
|
<Button
|
|
variant='ghost'
|
|
size='icon'
|
|
className='size-9 text-muted-foreground hover:text-foreground'
|
|
onClick={() =>
|
|
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
|
|
}
|
|
>
|
|
{mounted ? (
|
|
resolvedTheme === 'dark' ? (
|
|
<Sun className='size-[18px]' />
|
|
) : (
|
|
<Moon className='size-[18px]' />
|
|
)
|
|
) : (
|
|
<Moon className='size-[18px]' />
|
|
)}
|
|
<span className='sr-only'>{t('toggleTheme')}</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{mounted && (
|
|
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
|
|
)}
|
|
</header>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AppBanner() {
|
|
const t = useTranslations('layout.header');
|
|
const [isVisible, setIsVisible] = useState(false);
|
|
const bannerKey = 'ldc-banner-dismissed-script';
|
|
|
|
useEffect(() => {
|
|
const dismissed = localStorage.getItem(bannerKey);
|
|
if (!dismissed) {
|
|
setIsVisible(true);
|
|
}
|
|
}, []);
|
|
|
|
const handleDismiss = () => {
|
|
localStorage.setItem(bannerKey, 'true');
|
|
setIsVisible(false);
|
|
};
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{isVisible && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
|
className='relative flex w-full items-center justify-center bg-muted/50 overflow-hidden'
|
|
>
|
|
<div className='flex w-full items-center justify-center px-4 pr-10 py-2 text-xs md:text-[13px] font-medium text-muted-foreground'>
|
|
<p className='flex flex-wrap items-center justify-center gap-x-2 text-center'>
|
|
<span className='text-foreground'>{t('bannerTitle')}</span>
|
|
<a
|
|
href='https://openflare.fyrn.link/changelog/'
|
|
target='_blank'
|
|
className='underline underline-offset-4 hover:text-foreground'
|
|
>
|
|
{t('bannerLink')}
|
|
</a>
|
|
</p>
|
|
<Button
|
|
variant='ghost'
|
|
size='icon'
|
|
className='absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground shrink-0'
|
|
onClick={handleDismiss}
|
|
>
|
|
<span className='sr-only'>{t('dismiss')}</span>
|
|
<svg
|
|
xmlns='http://www.w3.org/2000/svg'
|
|
width='14'
|
|
height='14'
|
|
viewBox='0 0 24 24'
|
|
fill='none'
|
|
stroke='currentColor'
|
|
strokeWidth='2'
|
|
strokeLinecap='round'
|
|
strokeLinejoin='round'
|
|
className='lucide lucide-x'
|
|
>
|
|
<path d='M18 6 6 18' />
|
|
<path d='m6 6 12 12' />
|
|
</svg>
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|