fix: proper light/dark theme support for all public pages
Docker Build / Build and Push Docker Image (push) Failing after 1m31s
Docker Build / Build and Push Docker Image (push) Failing after 1m31s
This commit is contained in:
+14
-19
@@ -58,7 +58,7 @@ function FooterLinkItem(props: { link: FooterLink }) {
|
||||
href={props.link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[#8B8D97] hover:text-[#00D2FF] text-sm transition-colors duration-200'
|
||||
className='text-muted-foreground hover:text-primary text-sm transition-colors duration-200'
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
@@ -68,16 +68,13 @@ function FooterLinkItem(props: { link: FooterLink }) {
|
||||
return (
|
||||
<Link
|
||||
to={props.link.href}
|
||||
className='text-[#8B8D97] hover:text-[#00D2FF] text-sm transition-colors duration-200'
|
||||
className='text-muted-foreground hover:text-primary text-sm transition-colors duration-200'
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Renders User Agreement / Privacy Policy links inline with the parent's
|
||||
// copyright row when either is configured in System Settings → Site. Emits
|
||||
// fragmented siblings so the parent flex container's gap controls spacing.
|
||||
function LegalLinks(props: { leadingSeparator?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const { status } = useStatus()
|
||||
@@ -104,13 +101,13 @@ function LegalLinks(props: { leadingSeparator?: boolean }) {
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.key}>
|
||||
{(props.leadingSeparator || index > 0) && (
|
||||
<span aria-hidden='true' className='text-[#5C5D66]'>
|
||||
<span aria-hidden='true' className='text-muted-foreground/50'>
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to={item.href}
|
||||
className='text-[#8B8D97] hover:text-[#00D2FF] transition-colors duration-200'
|
||||
className='text-muted-foreground hover:text-primary transition-colors duration-200'
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
@@ -120,18 +117,16 @@ function LegalLinks(props: { leadingSeparator?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
// inline=true returns just the inner span for composition in a parent flex
|
||||
// row. inline=false wraps in a centered/right-aligned div (default).
|
||||
function ProjectAttribution(props: { currentYear: number; inline?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const content = (
|
||||
<span className='text-[#5C5D66]'>
|
||||
<span className='text-muted-foreground/60'>
|
||||
© {props.currentYear}{' '}
|
||||
<a
|
||||
href='https://git.viaeon.com/admin/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-[#8B8D97] hover:text-[#00D2FF] font-medium transition-colors'
|
||||
className='text-muted-foreground hover:text-primary font-medium transition-colors'
|
||||
>
|
||||
{t('ModelsToken')}
|
||||
</a>
|
||||
@@ -142,7 +137,7 @@ function ProjectAttribution(props: { currentYear: number; inline?: boolean }) {
|
||||
return content
|
||||
}
|
||||
return (
|
||||
<div className='text-[#5C5D66] text-center text-xs sm:text-right'>
|
||||
<div className='text-muted-foreground/60 text-center text-xs sm:text-right'>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
@@ -164,17 +159,17 @@ export function Footer(props: FooterProps) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'border-[#1A1F29] bg-[#0A0E14] border-t',
|
||||
'border-border bg-card border-t',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='mx-auto w-full max-w-6xl px-6 py-5'>
|
||||
<div className='border-[#1A1F29] bg-[#0F1419] flex flex-col items-center justify-between gap-4 rounded-lg border px-4 py-4 sm:flex-row sm:px-5'>
|
||||
<div className='border-border bg-muted/50 flex flex-col items-center justify-between gap-4 rounded-lg border px-4 py-4 sm:flex-row sm:px-5'>
|
||||
<div
|
||||
className='text-[#8B8D97] min-w-0 text-center text-sm sm:text-left'
|
||||
className='text-muted-foreground min-w-0 text-center text-sm sm:text-left'
|
||||
dangerouslySetInnerHTML={{ __html: footerHtml }}
|
||||
/>
|
||||
<div className='border-[#1A1F29] text-[#5C6370] flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
|
||||
<div className='border-border text-muted-foreground/60 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-1 border-t pt-4 text-xs sm:w-auto sm:justify-end sm:border-t-0 sm:border-l sm:pt-0 sm:pl-5'>
|
||||
<LegalLinks />
|
||||
<ProjectAttribution currentYear={currentYear} inline />
|
||||
</div>
|
||||
@@ -186,7 +181,7 @@ export function Footer(props: FooterProps) {
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={cn('border-[#1A1F29] bg-[#0A0E14] border-t', props.className)}
|
||||
className={cn('border-border bg-background border-t', props.className)}
|
||||
>
|
||||
<div className='mx-auto max-w-5xl px-6 py-8'>
|
||||
<div className='flex flex-col items-center justify-between gap-4 sm:flex-row'>
|
||||
@@ -197,13 +192,13 @@ export function Footer(props: FooterProps) {
|
||||
alt={displayName}
|
||||
className='size-5 rounded object-contain'
|
||||
/>
|
||||
<span className='text-xs font-semibold tracking-tight text-[#C5C6D0]'>
|
||||
<span className='text-xs font-semibold tracking-tight text-foreground'>
|
||||
{displayName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Links + Copyright */}
|
||||
<div className='text-[#5C6370] flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-xs'>
|
||||
<div className='text-muted-foreground/60 flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-xs'>
|
||||
<span>© {currentYear} {displayName}</span>
|
||||
<LegalLinks leadingSeparator />
|
||||
<ProjectAttribution currentYear={currentYear} inline />
|
||||
|
||||
+225
-306
@@ -16,333 +16,281 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact admin@modelstoken.com
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link, useRouterState } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { NotificationPopover } from '@/components/notification-popover'
|
||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||
import { ThemeSwitch } from '@/components/theme-switch'
|
||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||
import type { TopNavLink } from '../types'
|
||||
import { HeaderLogo } from './header-logo'
|
||||
import { NotificationPopover } from '@/components/notification-popover'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { useNotifications } from '@/hooks/use-notifications'
|
||||
import { ProfileDropdown } from './profile-dropdown'
|
||||
|
||||
const AUTH_PROMPT_SECONDS = 5
|
||||
|
||||
type AuthPromptTarget = {
|
||||
title: string
|
||||
href: string
|
||||
}
|
||||
|
||||
export interface PublicHeaderProps {
|
||||
navLinks?: TopNavLink[]
|
||||
mobileLinks?: TopNavLink[]
|
||||
navContent?: React.ReactNode
|
||||
showThemeSwitch?: boolean
|
||||
showLanguageSwitcher?: boolean
|
||||
logo?: React.ReactNode
|
||||
siteName?: string
|
||||
homeUrl?: string
|
||||
leftContent?: React.ReactNode
|
||||
rightContent?: React.ReactNode
|
||||
showNavigation?: boolean
|
||||
showAuthButtons?: boolean
|
||||
showNotifications?: boolean
|
||||
function HeaderLogo({
|
||||
src,
|
||||
loading,
|
||||
logoLoaded,
|
||||
className,
|
||||
}: {
|
||||
src: string
|
||||
loading: boolean
|
||||
logoLoaded: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const [imgError, setImgError] = useState(false)
|
||||
if (loading || !logoLoaded || imgError) {
|
||||
return (
|
||||
<svg
|
||||
viewBox='0 0 24 24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={cn('text-primary', className)}
|
||||
>
|
||||
<path d='M12 2L2 7l10 5 10-5-10-5z' />
|
||||
<path d='M2 17l10 5 10-5' />
|
||||
<path d='M2 12l10 5 10-5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt='Logo'
|
||||
className={className}
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function PublicHeader(props: PublicHeaderProps) {
|
||||
const {
|
||||
navLinks = defaultTopNavLinks,
|
||||
showThemeSwitch = true,
|
||||
showLanguageSwitcher = true,
|
||||
logo: customLogo,
|
||||
siteName: customSiteName,
|
||||
homeUrl = '/',
|
||||
showAuthButtons = true,
|
||||
showNotifications = true,
|
||||
} = props
|
||||
|
||||
export function PublicHeader() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [authPromptTarget, setAuthPromptTarget] =
|
||||
useState<AuthPromptTarget | null>(null)
|
||||
const [authPromptSecondsLeft, setAuthPromptSecondsLeft] =
|
||||
useState(AUTH_PROMPT_SECONDS)
|
||||
const router = useRouterState()
|
||||
const pathname = router.location.pathname
|
||||
const { status } = useStatus()
|
||||
const { systemName, logo: systemLogo, loading, customLogo } = useSystemConfig()
|
||||
const { auth } = useAuthStore()
|
||||
const {
|
||||
systemName,
|
||||
logo: systemLogo,
|
||||
loading,
|
||||
logoLoaded,
|
||||
} = useSystemConfig()
|
||||
const dynamicLinks = useTopNavLinks()
|
||||
const isAuthenticated = !!auth.user
|
||||
const notifications = useNotifications()
|
||||
const routerState = useRouterState()
|
||||
const pathname = routerState.location.pathname
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [logoLoaded, setLogoLoaded] = useState(false)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const headerRef = useRef<HTMLElement>(null)
|
||||
|
||||
const user = auth.user
|
||||
const isAuthenticated = !!user
|
||||
const displaySiteName = customSiteName || systemName
|
||||
const links = dynamicLinks.length > 0 ? dynamicLinks : navLinks
|
||||
const homeUrl = isAuthenticated ? '/dashboard' : '/'
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20)
|
||||
onScroll()
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
const handleScroll = () => setScrolled(window.scrollY > 16)
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
handleScroll()
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image()
|
||||
img.onload = () => setLogoLoaded(true)
|
||||
img.src = systemLogo || '/logo.png'
|
||||
}, [systemLogo])
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = mobileOpen ? 'hidden' : ''
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [mobileOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authPromptTarget) return
|
||||
const displaySiteName = systemName || 'ModelsToken'
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
setAuthPromptSecondsLeft((seconds) => Math.max(seconds - 1, 0))
|
||||
}, 1000)
|
||||
const showLanguageSwitcher = status?.language_option_enabled !== false
|
||||
const showThemeSwitch = true
|
||||
const showNotifications = status?.notice_enabled || status?.announcement_enabled
|
||||
const showAuthButtons = !status?.self_use_mode_enabled
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
const redirect = authPromptTarget.href
|
||||
setAuthPromptTarget(null)
|
||||
navigate({ to: '/sign-in', search: { redirect } })
|
||||
}, AUTH_PROMPT_SECONDS * 1000)
|
||||
const links = [
|
||||
{ title: 'Home', href: '/' },
|
||||
...(status?.pricing_enabled !== false ? [{ title: 'Pricing', href: '/pricing' }] : []),
|
||||
{ title: 'About', href: '/about' },
|
||||
].filter(Boolean) as { title: string; href: string; external?: boolean; disabled?: boolean }[]
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId)
|
||||
window.clearTimeout(timeoutId)
|
||||
const handleNavLinkClick = (
|
||||
event: React.MouseEvent,
|
||||
link: { href: string; disabled?: boolean },
|
||||
isMobile = false
|
||||
) => {
|
||||
if (link.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}, [authPromptTarget, navigate])
|
||||
|
||||
const closeAuthPrompt = useCallback(() => {
|
||||
setAuthPromptTarget(null)
|
||||
setAuthPromptSecondsLeft(AUTH_PROMPT_SECONDS)
|
||||
}, [])
|
||||
|
||||
const navigateToSignIn = useCallback(() => {
|
||||
const redirect = authPromptTarget?.href || '/'
|
||||
setAuthPromptTarget(null)
|
||||
navigate({ to: '/sign-in', search: { redirect } })
|
||||
}, [authPromptTarget?.href, navigate])
|
||||
|
||||
const handleNavLinkClick = useCallback(
|
||||
(
|
||||
event: React.MouseEvent<HTMLAnchorElement>,
|
||||
link: TopNavLink,
|
||||
closeMobile = false
|
||||
) => {
|
||||
if (link.disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (link.requiresAuth) {
|
||||
event.preventDefault()
|
||||
if (closeMobile) {
|
||||
setMobileOpen(false)
|
||||
}
|
||||
setAuthPromptSecondsLeft(AUTH_PROMPT_SECONDS)
|
||||
setAuthPromptTarget({
|
||||
title: t(link.title),
|
||||
href: link.href,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (closeMobile) {
|
||||
setMobileOpen(false)
|
||||
}
|
||||
},
|
||||
[t]
|
||||
)
|
||||
if (isMobile) {
|
||||
setMobileOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className='pointer-events-none fixed inset-x-0 top-0 z-50'>
|
||||
<div
|
||||
<header ref={headerRef} className='fixed top-0 right-0 left-0 z-50 px-3 pt-3 sm:px-4 sm:pt-4'>
|
||||
<div className='mx-auto max-w-5xl'>
|
||||
<nav
|
||||
className={cn(
|
||||
'pointer-events-auto mx-auto transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
scrolled ? 'max-w-[52rem] px-3 pt-3' : 'max-w-7xl px-4 pt-0 md:px-6'
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
className={cn(
|
||||
'flex items-center justify-between transition-all duration-700 ease-[cubic-bezier(0.16,1,0.3,1)]',
|
||||
scrolled
|
||||
? 'bg-[#0A0E14]/80 ring-[#1A1F29]/80 h-12 rounded-2xl pr-1.5 pl-4 shadow-[0_2px_16px_-6px_rgba(0,0,0,0.5)] ring-[0.5px] backdrop-blur-2xl'
|
||||
? 'bg-background/80 ring-border/80 h-12 rounded-2xl pr-1.5 pl-4 shadow-sm ring-[0.5px] backdrop-blur-2xl'
|
||||
: 'h-16 px-2'
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to={homeUrl}
|
||||
className='group flex shrink-0 items-center gap-2.5'
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to={homeUrl}
|
||||
className='group flex shrink-0 items-center gap-2.5'
|
||||
>
|
||||
<div className='flex size-7 shrink-0 items-center justify-center transition-all duration-300 group-hover:scale-105'>
|
||||
{loading ? (
|
||||
<Skeleton className='size-full rounded-lg' />
|
||||
) : customLogo ? (
|
||||
customLogo
|
||||
) : (
|
||||
<HeaderLogo
|
||||
src={systemLogo}
|
||||
loading={loading}
|
||||
logoLoaded={logoLoaded}
|
||||
className='size-full rounded-lg object-contain'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className='text-sm font-semibold tracking-tight text-white'>
|
||||
{loading ? <Skeleton className='h-4 w-16' /> : displaySiteName}
|
||||
</span>
|
||||
</Link>
|
||||
<div className='flex size-7 shrink-0 items-center justify-center transition-all duration-300 group-hover:scale-105'>
|
||||
{loading ? (
|
||||
<div className='size-full animate-pulse rounded-lg bg-muted' />
|
||||
) : customLogo ? (
|
||||
customLogo
|
||||
) : (
|
||||
<HeaderLogo
|
||||
src={systemLogo}
|
||||
loading={loading}
|
||||
logoLoaded={logoLoaded}
|
||||
className='size-full rounded-lg object-contain'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className='text-sm font-semibold tracking-tight text-foreground'>
|
||||
{loading ? <div className='h-4 w-16 animate-pulse rounded bg-muted' /> : displaySiteName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<div className='hidden items-center gap-0.5 sm:flex'>
|
||||
{links.map((link, i) => {
|
||||
const isActive = pathname === link.href
|
||||
if (link.external) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-disabled={link.disabled}
|
||||
tabIndex={link.disabled ? -1 : undefined}
|
||||
onClick={(event) => handleNavLinkClick(event, link)}
|
||||
className={cn(
|
||||
'text-[#8B8D97] hover:text-[#C5C6D0] rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
|
||||
link.disabled && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
{t(link.title)}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{/* Desktop nav */}
|
||||
<div className='hidden items-center gap-0.5 sm:flex'>
|
||||
{links.map((link, i) => {
|
||||
const isActive = pathname === link.href
|
||||
if (link.external) {
|
||||
return (
|
||||
<Link
|
||||
<a
|
||||
key={i}
|
||||
to={link.href}
|
||||
disabled={link.disabled}
|
||||
href={link.href}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-disabled={link.disabled}
|
||||
tabIndex={link.disabled ? -1 : undefined}
|
||||
onClick={(event) => handleNavLinkClick(event, link)}
|
||||
className={cn(
|
||||
'relative rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-[#00D2FF]'
|
||||
: 'text-[#8B8D97] hover:text-[#C5C6D0]',
|
||||
'text-muted-foreground hover:text-foreground rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
|
||||
link.disabled && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
{t(link.title)}
|
||||
{isActive && (
|
||||
<span className='absolute bottom-0 left-3 right-3 h-[2px] rounded-full bg-[#00D2FF]' />
|
||||
)}
|
||||
</Link>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
|
||||
{(showLanguageSwitcher ||
|
||||
showThemeSwitch ||
|
||||
showNotifications) && (
|
||||
<div className='mx-2 h-4 w-px bg-white/10' />
|
||||
)}
|
||||
|
||||
{showLanguageSwitcher && <LanguageSwitcher className='text-white/70 hover:bg-white/10 hover:text-white dark:hover:bg-white/10' />}
|
||||
{showThemeSwitch && <ThemeSwitch className='text-white/70 hover:bg-white/10 hover:text-white dark:hover:bg-white/10' />}
|
||||
{showNotifications && (
|
||||
<NotificationPopover
|
||||
className='text-white/70 hover:bg-white/10 hover:text-white dark:hover:bg-white/10'
|
||||
open={notifications.popoverOpen}
|
||||
onOpenChange={notifications.setPopoverOpen}
|
||||
unreadCount={notifications.unreadCount}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAuthButtons && (
|
||||
<>
|
||||
<div className='mx-1 h-4 w-px bg-white/10' />
|
||||
{loading ? (
|
||||
<Skeleton className='h-8 w-20 rounded-lg' />
|
||||
) : isAuthenticated ? (
|
||||
<ProfileDropdown />
|
||||
) : (
|
||||
<Button
|
||||
size='sm'
|
||||
className='h-8 rounded-lg bg-[#00D2FF] px-3.5 text-xs font-semibold text-[#0A0E14] hover:bg-[#00B8E6]'
|
||||
render={<Link to='/sign-in' />}
|
||||
>
|
||||
{t('Sign in')}
|
||||
</Button>
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
to={link.href}
|
||||
disabled={link.disabled}
|
||||
onClick={(event) => handleNavLinkClick(event, link)}
|
||||
className={cn(
|
||||
'relative rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors duration-200',
|
||||
isActive
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
link.disabled && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
>
|
||||
{t(link.title)}
|
||||
{isActive && (
|
||||
<span className='absolute bottom-0 left-3 right-3 h-[2px] rounded-full bg-primary' />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Mobile: compact actions + hamburger */}
|
||||
<div className='flex items-center gap-2 sm:hidden'>
|
||||
{showThemeSwitch && <ThemeSwitch className='text-white/70 hover:bg-white/10 hover:text-white dark:hover:bg-white/10' />}
|
||||
{showAuthButtons && !loading && isAuthenticated && (
|
||||
<ProfileDropdown />
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-9'
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
aria-label={t('Toggle navigation menu')}
|
||||
>
|
||||
<div className='relative size-4'>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-x-0 block h-[1.5px] origin-center rounded-full bg-current transition-all duration-300',
|
||||
mobileOpen ? 'top-[7px] rotate-45' : 'top-[3px]'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-x-0 top-[7px] block h-[1.5px] rounded-full bg-current transition-all duration-300',
|
||||
mobileOpen ? 'scale-x-0 opacity-0' : 'opacity-100'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-x-0 block h-[1.5px] origin-center rounded-full bg-current transition-all duration-300',
|
||||
mobileOpen ? 'top-[7px] -rotate-45' : 'top-[11px]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
{(showLanguageSwitcher ||
|
||||
showThemeSwitch ||
|
||||
showNotifications) && (
|
||||
<div className='mx-2 h-4 w-px bg-border' />
|
||||
)}
|
||||
|
||||
{showLanguageSwitcher && <LanguageSwitcher className='text-muted-foreground hover:bg-accent hover:text-foreground' />}
|
||||
{showThemeSwitch && <ThemeSwitch className='text-muted-foreground hover:bg-accent hover:text-foreground' />}
|
||||
{showNotifications && (
|
||||
<NotificationPopover
|
||||
className='text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
open={notifications.popoverOpen}
|
||||
onOpenChange={notifications.setPopoverOpen}
|
||||
unreadCount={notifications.unreadCount}
|
||||
activeTab={notifications.activeTab}
|
||||
onTabChange={notifications.setActiveTab}
|
||||
notice={notifications.notice}
|
||||
announcements={notifications.announcements}
|
||||
loading={notifications.loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAuthButtons && (
|
||||
<>
|
||||
<div className='mx-1 h-4 w-px bg-border' />
|
||||
{loading ? (
|
||||
<div className='h-8 w-20 animate-pulse rounded-lg bg-muted' />
|
||||
) : isAuthenticated ? (
|
||||
<ProfileDropdown />
|
||||
) : (
|
||||
<Button
|
||||
size='sm'
|
||||
className='h-8 rounded-lg px-3.5 text-xs font-semibold'
|
||||
render={<Link to='/sign-in' />}
|
||||
>
|
||||
{t('Sign in')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: compact actions + hamburger */}
|
||||
<div className='flex items-center gap-2 sm:hidden'>
|
||||
{showThemeSwitch && <ThemeSwitch className='text-muted-foreground hover:bg-accent hover:text-foreground' />}
|
||||
{showAuthButtons && !loading && isAuthenticated && (
|
||||
<ProfileDropdown />
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-9 text-foreground'
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
aria-label={t('Toggle navigation menu')}
|
||||
>
|
||||
<div className='relative size-4'>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-x-0 block h-[1.5px] origin-center rounded-full bg-current transition-all duration-300',
|
||||
mobileOpen ? 'top-[7px] rotate-45' : 'top-[3px]'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-x-0 block h-[1.5px] origin-center rounded-full bg-current transition-all duration-300',
|
||||
mobileOpen ? 'top-[7px] -rotate-45' : 'top-[11px]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile full-screen overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'bg-[#0A0E14]/98 fixed inset-0 z-40 backdrop-blur-2xl transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] sm:pointer-events-none sm:hidden',
|
||||
'bg-background/98 fixed inset-0 z-40 backdrop-blur-2xl transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] sm:pointer-events-none sm:hidden',
|
||||
mobileOpen
|
||||
? 'pointer-events-auto opacity-100'
|
||||
: 'pointer-events-none opacity-0'
|
||||
@@ -408,7 +356,7 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
<Link
|
||||
to={isAuthenticated ? '/dashboard' : '/sign-in'}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className='bg-[#00D2FF] text-[#0A0E14] inline-flex h-10 items-center justify-center rounded-lg text-sm font-semibold transition-opacity hover:opacity-90 active:opacity-80'
|
||||
className='bg-primary text-primary-foreground inline-flex h-10 items-center justify-center rounded-lg text-sm font-semibold transition-opacity hover:opacity-90 active:opacity-80'
|
||||
>
|
||||
{isAuthenticated ? t('Go to Dashboard') : t('Sign in')}
|
||||
</Link>
|
||||
@@ -416,35 +364,6 @@ export function PublicHeader(props: PublicHeaderProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={!!authPromptTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeAuthPrompt()
|
||||
}
|
||||
}}
|
||||
title={t('Sign in required')}
|
||||
description={t('Please sign in to view {{module}}.', {
|
||||
module: authPromptTarget?.title || '',
|
||||
})}
|
||||
contentClassName='sm:max-w-md'
|
||||
contentHeight='auto'
|
||||
footer={
|
||||
<>
|
||||
<Button variant='outline' onClick={closeAuthPrompt}>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button onClick={navigateToSignIn}>{t('Sign in now')}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className='bg-muted/40 text-muted-foreground rounded-lg px-3 py-2 text-sm'>
|
||||
{t('Redirecting to sign in in {{seconds}} seconds.', {
|
||||
seconds: authPromptSecondsLeft,
|
||||
})}
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@ export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const { systemName, logo, loading } = useSystemConfig()
|
||||
|
||||
return (
|
||||
<div className='dark relative grid h-svh max-w-none bg-[#0A0E14]'>
|
||||
<div className='relative grid h-svh max-w-none bg-background'>
|
||||
<Link
|
||||
to='/'
|
||||
className='absolute top-4 left-4 z-10 flex items-center gap-2 transition-opacity hover:opacity-80 sm:top-8 sm:left-8'
|
||||
@@ -49,7 +49,7 @@ export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
{loading ? (
|
||||
<Skeleton className='h-5 w-20' />
|
||||
) : (
|
||||
<h1 className='text-base font-semibold text-white'>{systemName}</h1>
|
||||
<h1 className='text-base font-semibold text-foreground'>{systemName}</h1>
|
||||
)}
|
||||
</Link>
|
||||
<div className='container flex items-center pt-16 sm:pt-0'>
|
||||
|
||||
+1
-1
@@ -112,7 +112,7 @@ export function ForgotPasswordForm({
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 !bg-[#00D2FF] !text-[#0A0E14] hover:!bg-[#00B8E6]'
|
||||
className='mt-2'
|
||||
disabled={isLoading || isActive || !turnstileReady}
|
||||
>
|
||||
{isActive
|
||||
|
||||
@@ -203,7 +203,7 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full !bg-[#00D2FF] !text-[#0A0E14] hover:!bg-[#00B8E6]'
|
||||
className='mt-2 w-full'
|
||||
disabled={!isFormValid || isLoading}
|
||||
>
|
||||
{isLoading ? <Loader2 className='h-4 w-4 animate-spin' /> : null}
|
||||
|
||||
@@ -374,7 +374,7 @@ export function UserAuthForm({
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2 !bg-[#00D2FF] !text-[#0A0E14] hover:!bg-[#00B8E6]'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ export function SignIn() {
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight text-white sm:text-left'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Sign in')}
|
||||
</h2>
|
||||
{!status?.self_use_mode_enabled &&
|
||||
|
||||
@@ -354,7 +354,7 @@ export function SignUpForm({
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
className='mt-2 w-full justify-center gap-2 !bg-[#00D2FF] !text-[#0A0E14] hover:!bg-[#00B8E6]'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={
|
||||
isLoading ||
|
||||
(requiresLegalConsent && !agreedToLegal) ||
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ export function SignUp() {
|
||||
<AuthLayout>
|
||||
<div className='w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight text-white sm:text-left'>
|
||||
<h2 className='text-center text-2xl font-semibold tracking-tight sm:text-left'>
|
||||
{t('Create an account')}
|
||||
</h2>
|
||||
<p className='text-muted-foreground text-left text-sm sm:text-base'>
|
||||
|
||||
+49
-89
@@ -27,20 +27,6 @@ interface HeroProps {
|
||||
isAuthenticated?: boolean
|
||||
}
|
||||
|
||||
const T = {
|
||||
bg: '#0A0E14',
|
||||
surface: '#0F1419',
|
||||
border: '#1A1F29',
|
||||
accent: '#00D2FF',
|
||||
accentDim: '#0099BB',
|
||||
green: '#28C840',
|
||||
yellow: '#FEBC2E',
|
||||
gray: '#5C6370',
|
||||
grayLight: '#8B8D97',
|
||||
white: '#C5C6D0',
|
||||
red: '#FF5F57',
|
||||
}
|
||||
|
||||
const features = [
|
||||
{ icon: Zap, title: 'Multi-model Routing', desc: 'Intelligent routing with automatic failover and load balancing' },
|
||||
{ icon: Key, title: 'Key Management', desc: 'Centralized API key lifecycle management with usage tracking' },
|
||||
@@ -58,22 +44,22 @@ export function Hero(props: HeroProps) {
|
||||
`${window.location.origin}`
|
||||
|
||||
return (
|
||||
<section className='relative overflow-hidden' style={{ backgroundColor: T.bg }}>
|
||||
<section className='relative overflow-hidden bg-background'>
|
||||
{/* Grid background texture */}
|
||||
<div
|
||||
aria-hidden
|
||||
className='pointer-events-none absolute inset-0 opacity-[0.03]'
|
||||
className='pointer-events-none absolute inset-0 opacity-[0.03] dark:opacity-[0.03]'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(rgba(0,210,255,0.5) 1px, transparent 1px), linear-gradient(90deg, rgba(0,210,255,0.5) 1px, transparent 1px)',
|
||||
'linear-gradient(var(--primary) 1px, transparent 1px), linear-gradient(90deg, var(--primary) 1px, transparent 1px)',
|
||||
backgroundSize: '64px 64px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top glow */}
|
||||
{/* Top glow - dark mode only */}
|
||||
<div
|
||||
aria-hidden
|
||||
className='pointer-events-none absolute inset-0'
|
||||
className='pointer-events-none absolute inset-0 hidden dark:block'
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(ellipse 50% 40% at 50% 0%, rgba(0,210,255,0.08) 0%, transparent 70%)',
|
||||
@@ -84,16 +70,13 @@ export function Hero(props: HeroProps) {
|
||||
{/* ── Hero ── */}
|
||||
<div className='flex min-h-[calc(100svh-3rem)] flex-col items-center justify-center py-20 text-center'>
|
||||
{/* Headline */}
|
||||
<h1
|
||||
className='max-w-2xl text-[clamp(2.2rem,6vw,4.2rem)] leading-[1.05] font-bold tracking-[-0.03em]'
|
||||
style={{ color: T.white }}
|
||||
>
|
||||
<h1 className='max-w-2xl text-[clamp(2.2rem,6vw,4.2rem)] leading-[1.05] font-bold tracking-[-0.03em] text-foreground'>
|
||||
{t('Unified LLM')}{' '}
|
||||
<span style={{ color: T.accent }}>{t('Gateway')}</span>
|
||||
<span className='text-primary'>{t('Gateway')}</span>
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p className='mt-5 max-w-md text-[15px] leading-relaxed' style={{ color: T.grayLight }}>
|
||||
<p className='mt-5 max-w-md text-[15px] leading-relaxed text-muted-foreground'>
|
||||
{t('One endpoint for all models. OpenAI-compatible, switch and go.')}
|
||||
</p>
|
||||
|
||||
@@ -101,8 +84,7 @@ export function Hero(props: HeroProps) {
|
||||
<div className='mt-9 flex items-center gap-4'>
|
||||
{props.isAuthenticated ? (
|
||||
<Button
|
||||
className='group h-11 rounded-lg px-7 text-sm font-semibold hover:opacity-90'
|
||||
style={{ backgroundColor: T.accent, color: T.bg }}
|
||||
className='group h-11 rounded-lg px-7 text-sm font-semibold'
|
||||
render={<Link to='/dashboard' />}
|
||||
>
|
||||
{t('Go to Dashboard')}
|
||||
@@ -111,8 +93,7 @@ export function Hero(props: HeroProps) {
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className='group h-11 rounded-lg px-7 text-sm font-semibold hover:opacity-90'
|
||||
style={{ backgroundColor: T.accent, color: T.bg }}
|
||||
className='group h-11 rounded-lg px-7 text-sm font-semibold'
|
||||
render={<Link to='/sign-up' />}
|
||||
>
|
||||
{t('Get Started')}
|
||||
@@ -120,10 +101,7 @@ export function Hero(props: HeroProps) {
|
||||
</Button>
|
||||
<Link
|
||||
to='/pricing'
|
||||
className='text-[13px] font-medium transition-colors duration-200'
|
||||
style={{ color: T.grayLight }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = T.accent }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = T.grayLight }}
|
||||
className='text-[13px] font-medium text-muted-foreground transition-colors duration-200 hover:text-primary'
|
||||
>
|
||||
{t('View Pricing')} →
|
||||
</Link>
|
||||
@@ -134,58 +112,55 @@ export function Hero(props: HeroProps) {
|
||||
|
||||
{/* ── Terminal demo ── */}
|
||||
<div className='-mt-6 pb-24'>
|
||||
<div
|
||||
className='mx-auto w-full max-w-2xl overflow-hidden rounded-xl border'
|
||||
style={{ borderColor: T.border, backgroundColor: T.surface, boxShadow: '0 0 80px -20px rgba(0,210,255,0.1)' }}
|
||||
>
|
||||
<div className='mx-auto w-full max-w-2xl overflow-hidden rounded-xl border border-border bg-card shadow-lg dark:shadow-[0_0_80px_-20px_rgba(0,210,255,0.1)]'>
|
||||
{/* Terminal header */}
|
||||
<div className='flex items-center gap-2 border-b px-4 py-2.5' style={{ borderColor: T.border }}>
|
||||
<div className='flex items-center gap-2 border-b border-border px-4 py-2.5'>
|
||||
<div className='flex gap-1.5'>
|
||||
<div className='size-2.5 rounded-full' style={{ backgroundColor: T.red }} />
|
||||
<div className='size-2.5 rounded-full' style={{ backgroundColor: T.yellow }} />
|
||||
<div className='size-2.5 rounded-full' style={{ backgroundColor: T.green }} />
|
||||
<div className='size-2.5 rounded-full bg-red-500' />
|
||||
<div className='size-2.5 rounded-full bg-yellow-500' />
|
||||
<div className='size-2.5 rounded-full bg-green-500' />
|
||||
</div>
|
||||
<span className='ml-2 text-[11px]' style={{ color: T.gray }}>bash — api</span>
|
||||
<span className='ml-2 text-[11px] text-muted-foreground'>bash — api</span>
|
||||
</div>
|
||||
{/* Terminal content */}
|
||||
<div className='p-5 font-mono text-[13px] leading-[1.85]'>
|
||||
<div>
|
||||
<span style={{ color: T.accent }}>$</span>{' '}
|
||||
<span style={{ color: T.white }}>curl</span>{' '}
|
||||
<span style={{ color: T.accent }}>{serverAddress}/v1/chat/completions</span>{' '}
|
||||
<span style={{ color: T.gray }}>\</span>
|
||||
<span className='text-primary'>$</span>{' '}
|
||||
<span className='text-foreground'>curl</span>{' '}
|
||||
<span className='text-primary'>{serverAddress}/v1/chat/completions</span>{' '}
|
||||
<span className='text-muted-foreground'>\</span>
|
||||
</div>
|
||||
<div className='pl-5'>
|
||||
<span style={{ color: T.gray }}>-H</span>{' '}
|
||||
<span style={{ color: T.yellow }}>"Authorization: Bearer sk-..."</span>{' '}
|
||||
<span style={{ color: T.gray }}>\</span>
|
||||
<span className='text-muted-foreground'>-H</span>{' '}
|
||||
<span className='text-yellow-600 dark:text-yellow-400">"Authorization: Bearer sk-..."</span>{' '}
|
||||
<span className='text-muted-foreground'>\</span>
|
||||
</div>
|
||||
<div className='pl-5'>
|
||||
<span style={{ color: T.gray }}>-d</span>{' '}
|
||||
<span style={{ color: T.yellow }}>{"'{ \"model\": \"gpt-4o\", \"messages\": [...] }'"}</span>
|
||||
<span className='text-muted-foreground'>-d</span>{' '}
|
||||
<span className='text-yellow-600 dark:text-yellow-400">{"'{ \"model\": \"gpt-4o\", \"messages\": [...] }'"}</span>
|
||||
</div>
|
||||
|
||||
<div className='mt-5 border-t pt-4' style={{ borderColor: T.border }}>
|
||||
<div className='flex items-center gap-2 mb-3'>
|
||||
<span className='inline-block size-1.5 rounded-full' style={{ backgroundColor: T.green }} />
|
||||
<span className='text-[11px] font-medium' style={{ color: T.gray }}>200 OK</span>
|
||||
<span className='text-[11px]' style={{ color: T.gray }}>312ms</span>
|
||||
<div className='mt-5 border-t border-border pt-4'>
|
||||
<div className='mb-3 flex items-center gap-2'>
|
||||
<span className='inline-block size-1.5 rounded-full bg-green-500' />
|
||||
<span className='text-[11px] font-medium text-muted-foreground'>200 OK</span>
|
||||
<span className='text-[11px] text-muted-foreground'>312ms</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: T.gray }}>{"{"}</span>
|
||||
<span style={{ color: T.accent }}> "model"</span>
|
||||
<span style={{ color: T.gray }}>:</span>
|
||||
<span style={{ color: T.yellow }}> "gpt-4o"</span>
|
||||
<span style={{ color: T.gray }}>,</span>
|
||||
<span style={{ color: T.accent }}> "usage"</span>
|
||||
<span style={{ color: T.gray }}>:</span>
|
||||
<span style={{ color: T.gray }}> {"{ \"prompt_tokens\": 12, \"completion_tokens\": 47 }"}</span>
|
||||
<span style={{ color: T.gray }}>{"}"}</span>
|
||||
<span className='text-muted-foreground'>{"{"}</span>
|
||||
<span className='text-primary'> "model"</span>
|
||||
<span className='text-muted-foreground'>:</span>
|
||||
<span className='text-yellow-600 dark:text-yellow-400'> "gpt-4o"</span>
|
||||
<span className='text-muted-foreground'>,</span>
|
||||
<span className='text-primary'> "usage"</span>
|
||||
<span className='text-muted-foreground'>:</span>
|
||||
<span className='text-muted-foreground'> {"{ \"prompt_tokens\": 12, \"completion_tokens\": 47 }"}</span>
|
||||
<span className='text-muted-foreground'>{"}"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-3'>
|
||||
<span style={{ color: T.accent }}>$</span>
|
||||
<span className='ml-1 inline-block h-3.5 w-[7px] animate-pulse align-middle' style={{ backgroundColor: T.accent }} />
|
||||
<span className='text-primary'>$</span>
|
||||
<span className='ml-1 inline-block h-3.5 w-[7px] animate-pulse bg-primary align-middle' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,10 +169,10 @@ export function Hero(props: HeroProps) {
|
||||
{/* ── Features ── */}
|
||||
<div className='pb-28'>
|
||||
<div className='mb-14 text-center'>
|
||||
<h2 className='text-xl font-semibold tracking-tight' style={{ color: T.white }}>
|
||||
<h2 className='text-xl font-semibold tracking-tight text-foreground'>
|
||||
{t('Full-featured gateway')}
|
||||
</h2>
|
||||
<p className='mt-2 text-sm' style={{ color: T.grayLight }}>
|
||||
<p className='mt-2 text-sm text-muted-foreground'>
|
||||
{t('Everything you need to manage, route, and monitor LLM API traffic')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -206,30 +181,15 @@ export function Hero(props: HeroProps) {
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className='group rounded-lg border p-5 transition-all duration-200'
|
||||
style={{
|
||||
borderColor: T.border,
|
||||
backgroundColor: T.surface,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = T.accentDim
|
||||
e.currentTarget.style.boxShadow = '0 0 24px -8px rgba(0,210,255,0.15)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = T.border
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
className='group rounded-lg border border-border bg-card p-5 transition-all duration-200 hover:border-primary/50 hover:shadow-md dark:hover:shadow-[0_0_24px_-8px_rgba(0,210,255,0.15)]'
|
||||
>
|
||||
<div
|
||||
className='mb-3 flex size-9 items-center justify-center rounded-md'
|
||||
style={{ backgroundColor: `${T.accent}10` }}
|
||||
>
|
||||
<feature.icon className='size-4' style={{ color: T.accent }} />
|
||||
<div className='mb-3 flex size-9 items-center justify-center rounded-md bg-primary/10'>
|
||||
<feature.icon className='size-4 text-primary' />
|
||||
</div>
|
||||
<h3 className='text-sm font-semibold' style={{ color: T.white }}>
|
||||
<h3 className='text-sm font-semibold text-foreground'>
|
||||
{t(feature.title)}
|
||||
</h3>
|
||||
<p className='mt-1.5 text-[13px] leading-relaxed' style={{ color: T.gray }}>
|
||||
<p className='mt-1.5 text-[13px] leading-relaxed text-muted-foreground'>
|
||||
{t(feature.desc)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+2
-4
@@ -62,10 +62,8 @@ export function Home() {
|
||||
|
||||
return (
|
||||
<PublicLayout showMainContainer={false}>
|
||||
<div className='dark'>
|
||||
<Hero isAuthenticated={isAuthenticated} />
|
||||
<Footer />
|
||||
</div>
|
||||
<Hero isAuthenticated={isAuthenticated} />
|
||||
<Footer />
|
||||
</PublicLayout>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user