feat: clean navbar icons, floating theme bubble, auto theme on first visit
- Navbar: remove theme switcher, clean nav icons (no text labels), active state is accent dot underline + icon color — no pill bg - useTheme: drop 'system' from stored type; first visit = auto by system preference, stays unset until user explicitly clicks - ThemeBubble: fixed bottom-right pill with Sun/Moon icon + label, hover lift, click toggles light ↔ dark and persists to localStorage - App.tsx: render ThemeBubble inside ThemeProvider Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { ThemeProvider } from '@/hooks/useTheme';
|
|||||||
import { ToastProvider } from '@/components/Toast';
|
import { ToastProvider } from '@/components/Toast';
|
||||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||||
import Navbar from '@/components/Navbar';
|
import Navbar from '@/components/Navbar';
|
||||||
|
import ThemeBubble from '@/components/ThemeBubble';
|
||||||
import LoginPage from '@/pages/LoginPage';
|
import LoginPage from '@/pages/LoginPage';
|
||||||
import RegisterPage from '@/pages/RegisterPage';
|
import RegisterPage from '@/pages/RegisterPage';
|
||||||
import FeedPage from '@/pages/FeedPage';
|
import FeedPage from '@/pages/FeedPage';
|
||||||
@@ -107,6 +108,7 @@ export default function App() {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
<ThemeBubble />
|
||||||
<main>
|
<main>
|
||||||
<AnimatedRoutes />
|
<AnimatedRoutes />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,26 +1,17 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
|
||||||
import UserAvatar from '@/components/UserAvatar';
|
import UserAvatar from '@/components/UserAvatar';
|
||||||
import { Home, PlusSquare, Shield, Sun, Moon, Monitor } from 'lucide-react';
|
import { Home, PlusSquare, Shield } from 'lucide-react';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const cycleTheme = () => {
|
|
||||||
const next: Record<string, 'dark' | 'system' | 'light'> = { light: 'dark', dark: 'system', system: 'light' };
|
|
||||||
setTheme(next[theme]);
|
|
||||||
};
|
|
||||||
const ThemeIcon = theme === 'dark' ? Moon : theme === 'light' ? Sun : Monitor;
|
|
||||||
const themeLabel = theme === 'dark' ? 'Тёмная' : theme === 'light' ? 'Светлая' : 'Авто';
|
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const isProfile = location.pathname.startsWith('/profile');
|
const isFeed = location.pathname === '/feed' || location.pathname === '/';
|
||||||
const isFeed = location.pathname === '/feed' || location.pathname === '/';
|
|
||||||
const isUpload = location.pathname === '/upload';
|
const isUpload = location.pathname === '/upload';
|
||||||
|
const isProfile = location.pathname.startsWith('/profile');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="sticky top-0 z-50 nav-glass border-b border-[var(--border-light)]">
|
<nav className="sticky top-0 z-50 nav-glass border-b border-[var(--border-light)]">
|
||||||
@@ -36,34 +27,27 @@ export default function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Навигация */}
|
{/* Навигация */}
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-1">
|
||||||
<NavLink to="/feed" active={isFeed} icon={Home} label="Лента" />
|
<NavIcon to="/feed" active={isFeed} icon={Home} label="Лента" />
|
||||||
<NavLink to="/upload" active={isUpload} icon={PlusSquare} label="Добавить" />
|
<NavIcon to="/upload" active={isUpload} icon={PlusSquare} label="Добавить" />
|
||||||
|
|
||||||
{/* Переключатель темы */}
|
|
||||||
<button
|
|
||||||
onClick={cycleTheme}
|
|
||||||
title={`Тема: ${themeLabel}`}
|
|
||||||
className="flex flex-col items-center justify-center gap-0.5 h-11 w-11 rounded-xl transition-all text-[var(--fg-tertiary)] hover:text-[var(--fg-secondary)] hover:bg-[var(--border-light)]"
|
|
||||||
>
|
|
||||||
<ThemeIcon className="h-[18px] w-[18px]" strokeWidth={1.5} />
|
|
||||||
<span className="text-[9px] font-semibold uppercase tracking-wide hidden sm:block opacity-60">{themeLabel}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Аватар */}
|
{/* Аватар */}
|
||||||
<Link to="/profile" className="ml-1 relative flex items-center">
|
<Link
|
||||||
<div className={`flex items-center justify-center h-9 w-9 rounded-full transition-all duration-200 ${
|
to="/profile"
|
||||||
|
title="Профиль"
|
||||||
|
className={[
|
||||||
|
'relative ml-1 flex items-center rounded-full transition-all duration-200',
|
||||||
isProfile
|
isProfile
|
||||||
? 'ring-[2.5px] ring-[var(--accent)] ring-offset-2 ring-offset-[var(--bg)]'
|
? 'ring-[2.5px] ring-[var(--accent)] ring-offset-2 ring-offset-[var(--bg)]'
|
||||||
: 'opacity-80 hover:opacity-100'
|
: 'opacity-75 hover:opacity-100',
|
||||||
}`}>
|
].join(' ')}
|
||||||
<UserAvatar
|
>
|
||||||
avatarUrl={user.avatar_url}
|
<UserAvatar
|
||||||
username={user.username}
|
avatarUrl={user.avatar_url}
|
||||||
className="h-9 w-9"
|
username={user.username}
|
||||||
fallbackClassName="text-[11px] avatar-colored bg-[var(--accent)]"
|
className="h-8 w-8"
|
||||||
/>
|
fallbackClassName="text-[11px] avatar-colored bg-[var(--accent)]"
|
||||||
</div>
|
/>
|
||||||
{user.is_admin && (
|
{user.is_admin && (
|
||||||
<span className="absolute -top-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-[var(--accent)] flex items-center justify-center ring-2 ring-[var(--bg)]">
|
<span className="absolute -top-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-[var(--accent)] flex items-center justify-center ring-2 ring-[var(--bg)]">
|
||||||
<Shield className="h-2 w-2 text-white" strokeWidth={3} />
|
<Shield className="h-2 w-2 text-white" strokeWidth={3} />
|
||||||
@@ -76,22 +60,27 @@ export default function Navbar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavLink({
|
function NavIcon({
|
||||||
to, active, icon: Icon, label,
|
to, active, icon: Icon, label,
|
||||||
}: {
|
}: {
|
||||||
to: string; active: boolean; icon: any; label: string;
|
to: string; active: boolean; icon: React.ComponentType<any>; label: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
className={`flex flex-col items-center justify-center gap-0.5 h-11 w-11 rounded-xl transition-all duration-200 ${
|
title={label}
|
||||||
|
className={[
|
||||||
|
'relative flex items-center justify-center h-9 w-9 rounded-xl',
|
||||||
|
'transition-all duration-200',
|
||||||
active
|
active
|
||||||
? 'bg-[var(--accent-soft)] text-[var(--accent)]'
|
? 'text-[var(--accent)] bg-[var(--accent-soft)]'
|
||||||
: 'text-[var(--fg-tertiary)] hover:text-[var(--fg-secondary)] hover:bg-[var(--border-light)]'
|
: 'text-[var(--fg-tertiary)] hover:text-[var(--fg)] hover:bg-[var(--border-light)]',
|
||||||
}`}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<Icon className="h-[18px] w-[18px]" strokeWidth={active ? 2.2 : 1.5} />
|
<Icon className="h-5 w-5" strokeWidth={active ? 2.2 : 1.7} />
|
||||||
<span className="text-[9px] font-semibold uppercase tracking-wide hidden sm:block opacity-70">{label}</span>
|
{active && (
|
||||||
|
<span className="absolute -bottom-[11px] left-1/2 -translate-x-1/2 h-[2.5px] w-4 rounded-full bg-[var(--accent)]" />
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
31
client/src/components/ThemeBubble.tsx
Normal file
31
client/src/components/ThemeBubble.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Sun, Moon } from 'lucide-react';
|
||||||
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
|
||||||
|
export default function ThemeBubble() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
const isDark = resolvedTheme === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||||
|
title={isDark ? 'Светлая тема' : 'Тёмная тема'}
|
||||||
|
aria-label="Переключить тему"
|
||||||
|
className={[
|
||||||
|
'fixed bottom-5 right-5 z-40',
|
||||||
|
'flex items-center gap-2 h-10 px-3.5 rounded-full',
|
||||||
|
'border border-[var(--border)]',
|
||||||
|
'bg-[var(--surface)] shadow-[0_4px_20px_rgba(0,0,0,0.18)]',
|
||||||
|
'text-[var(--fg-secondary)] text-xs font-semibold',
|
||||||
|
'transition-all duration-200 ease-out',
|
||||||
|
'hover:shadow-[0_6px_24px_rgba(0,0,0,0.25)] hover:-translate-y-0.5',
|
||||||
|
'active:scale-95 active:translate-y-0',
|
||||||
|
'select-none cursor-pointer backdrop-blur-sm',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{isDark
|
||||||
|
? <><Sun className="h-4 w-4 text-amber-400" strokeWidth={1.8} /><span className="hidden sm:inline">Светлая</span></>
|
||||||
|
: <><Moon className="h-4 w-4 text-indigo-400" strokeWidth={1.8} /><span className="hidden sm:inline">Тёмная</span></>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,28 @@
|
|||||||
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
type StoredTheme = 'light' | 'dark';
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
theme: Theme;
|
|
||||||
resolvedTheme: 'light' | 'dark';
|
resolvedTheme: 'light' | 'dark';
|
||||||
setTheme: (t: Theme) => void;
|
setTheme: (t: StoredTheme) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue>({
|
const ThemeContext = createContext<ThemeContextValue>({
|
||||||
theme: 'system',
|
resolvedTheme: 'dark',
|
||||||
resolvedTheme: 'light',
|
|
||||||
setTheme: () => {},
|
setTheme: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setThemeState] = useState<Theme>(() => {
|
|
||||||
return (localStorage.getItem('theme') as Theme) ?? 'system';
|
|
||||||
});
|
|
||||||
|
|
||||||
const [systemDark, setSystemDark] = useState(() =>
|
const [systemDark, setSystemDark] = useState(() =>
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// null = пользователь ещё не выбирал → автоматически по системе
|
||||||
|
const [explicit, setExplicit] = useState<StoredTheme | null>(() => {
|
||||||
|
const saved = localStorage.getItem('theme');
|
||||||
|
return saved === 'light' || saved === 'dark' ? saved : null;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);
|
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);
|
||||||
@@ -30,21 +30,19 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => mq.removeEventListener('change', handler);
|
return () => mq.removeEventListener('change', handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resolvedTheme: 'light' | 'dark' =
|
const resolvedTheme: 'light' | 'dark' = explicit ?? (systemDark ? 'dark' : 'light');
|
||||||
theme === 'system' ? (systemDark ? 'dark' : 'light') : theme;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
document.documentElement.setAttribute('data-theme', resolvedTheme);
|
||||||
root.setAttribute('data-theme', resolvedTheme);
|
|
||||||
}, [resolvedTheme]);
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
const setTheme = (t: Theme) => {
|
const setTheme = (t: StoredTheme) => {
|
||||||
localStorage.setItem('theme', t);
|
localStorage.setItem('theme', t);
|
||||||
setThemeState(t);
|
setExplicit(t);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
<ThemeContext.Provider value={{ resolvedTheme, setTheme }}>
|
||||||
{children}
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user