fix: stabiliziraj navbar tranziciju i ukloni white splash

Zadrzava Navbar i ostale UI otoke kroz Astro tranzicije te sinkronizira aktivni path preko astro:page-load kako bi react-spring indikator animirao iz trenutne pozicije.

Uklanja bijeli splash pri promjeni stranice i prelasku teme postavljanjem html background boje inline, prilagodbom ThemeToggle logike i uklanjanjem body color tranzicije.

Dodano je i TTL kesiranje za dashboard/task dohvat kako bi se smanjili redundantni API pozivi pri navigaciji.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
mariomitte
2026-07-19 16:18:15 +02:00
parent fe7edf5643
commit bbdd02c9e9
7 changed files with 139 additions and 38 deletions

View File

@@ -2,7 +2,8 @@ import NotificationBell from './NotificationBell';
import ThemeToggle from './ui/ThemeToggle';
import AuthWidget from './AuthWidget';
import UserDisplay from './ui/UserDisplay';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSpring, animated } from '@react-spring/web';
import { hydrateAuthFromStorage } from '../stores/authStore';
const ITEMS = [
@@ -13,27 +14,82 @@ const ITEMS = [
{ id: 'clients', label: 'Klijenti', href: '/clients' },
];
export default function Navbar({ minimal = false }) {
const [pathname, setPathname] = useState('/');
const normalizedPath = useMemo(() => {
const value = String(pathname || '/');
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
return value;
}, [pathname]);
function normalizePath(p) {
const value = String(p || '/');
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
return value;
}
function getActiveIndex(path) {
const idx = ITEMS.findIndex((item) => item.href === path);
return idx >= 0 ? idx : 0;
}
export default function Navbar({ minimal = false }) {
// Navbar ostaje montiran (transition:persist) — pratimo promjene pathname preko astro:page-load
const [pathname, setPathname] = useState(() =>
typeof window !== 'undefined' ? normalizePath(window.location.pathname) : '/'
);
const itemRefs = useRef([]);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
// Na prvom renderu preskočimo animaciju (pill se pojavljuje na ispravnoj poziciji bez slide-a od 0)
const [skipAnimation, setSkipAnimation] = useState(true);
const activeIndex = getActiveIndex(pathname);
const measureIndicator = (currentIndex) => {
const idx = currentIndex ?? activeIndex;
const el = itemRefs.current[idx];
if (!el) return;
const parent = el.closest('ul');
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
setIndicatorStyle({
left: elRect.left - parentRect.left,
width: elRect.width,
});
};
// Mount: izmjeri početnu poziciju bez animacije, pa uključi animaciju za buduće navigacije
useEffect(() => {
const updatePath = () => setPathname(window.location.pathname || '/');
updatePath();
window.addEventListener('astro:page-load', updatePath);
// Hidratira auth iz storage pri prvi puta učitavanja Navbar-a
hydrateAuthFromStorage();
return () => window.removeEventListener('astro:page-load', updatePath);
const timer = setTimeout(() => {
measureIndicator();
setSkipAnimation(false);
}, 30);
// Sluša Astro view-transition navigaciju (Navbar ostaje montiran zahvaljujući transition:persist)
const handlePageLoad = () => {
setPathname(normalizePath(window.location.pathname));
};
document.addEventListener('astro:page-load', handlePageLoad);
return () => {
clearTimeout(timer);
document.removeEventListener('astro:page-load', handlePageLoad);
};
}, []);
const currentItem = useMemo(
() => ITEMS.find((item) => item.href === normalizedPath) || ITEMS[0],
[normalizedPath]
);
// Kad se promijeni activeIndex (nova stranica), animiraj pill na novu poziciju
useEffect(() => {
if (!skipAnimation) {
measureIndicator();
}
}, [activeIndex]);
// react-spring: animirani klizač
// immediate=true na prvom renderu → pill se ne animira od lijevog ruba, već skače na pravo mjesto
const springStyle = useSpring({
left: indicatorStyle.left,
width: indicatorStyle.width,
immediate: skipAnimation,
config: { tension: 340, friction: 28 },
});
const currentItem = ITEMS[activeIndex];
return (
<nav className="sticky top-0 z-30 mb-4 rounded-lg border border-border-hairline bg-canvas-elevated/95 px-4 py-3 backdrop-blur">
@@ -44,16 +100,29 @@ export default function Navbar({ minimal = false }) {
{!minimal && <UserDisplay field="ime_prezime" className="text-xs text-text-muted" />}
</a>
{/* Navigacijski linkovi */}
<ul className="hidden items-center gap-1 text-sm sm:flex">
{ITEMS.map((item) => (
<li key={item.id}>
{/* Navigacijski linkovi s react-spring klizačem */}
<ul className="relative hidden items-center gap-1 text-sm sm:flex">
{/* Animirani pozadinski klizač */}
<animated.li
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
bottom: 0,
borderRadius: '0.5rem',
backgroundColor: 'rgb(238 242 255)',
pointerEvents: 'none',
...springStyle,
}}
/>
{ITEMS.map((item, idx) => (
<li key={item.id} ref={(el) => (itemRefs.current[idx] = el)}>
<a
href={item.href}
className={
normalizedPath === item.href
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
pathname === item.href
? 'relative z-10 block rounded-lg px-3 py-2 font-medium text-indigo-700'
: 'relative z-10 block rounded-lg px-3 py-2 text-text-main hover:text-indigo-600'
}
>
{item.label}
@@ -64,6 +133,7 @@ export default function Navbar({ minimal = false }) {
{/* Desna strana */}
<div className="flex items-center gap-2">
{/* Mobilni dropdown */}
<details className="relative sm:hidden">
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline px-2 py-1 text-xs text-text-main hover:bg-canvas-deep">
{currentItem?.label || 'Izbornik'}
@@ -78,7 +148,7 @@ export default function Navbar({ minimal = false }) {
<a
href={item.href}
className={
normalizedPath === item.href
pathname === item.href
? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700'
: 'block px-3 py-2 text-sm text-text-main hover:bg-canvas-deep'
}