Files
OpenFlare/frontend/components/layout/header.tsx
T
ryan 454542c1d0 docs(i18n): 恢复并补齐英文版 vitepress,README 默认改为英文
- 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)可能落后于中文,需后续逐篇同步
2026-08-16 23:18:29 +08:00

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>
);
}