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'
}

View File

@@ -153,11 +153,6 @@ export default function FleetDashboardShell({ initialSection = 'dashboard', page
return () => window.removeEventListener('calendar:open-task', onCalendarOpenTask);
}, []);
useEffect(() => {
const nextSection = String(resolvedInitialSection || 'dashboard');
setActiveSection(nextSection);
}, [resolvedInitialSection]);
useEffect(() => {
if (typeof window === 'undefined') return;
const syncContextFromStorage = () => {

View File

@@ -11,10 +11,12 @@ export default function ThemeToggle() {
const toggleTheme = () => {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
document.documentElement.style.backgroundColor = '#ffffff';
localStorage.setItem('theme', 'light');
setIsDark(false);
} else {
document.documentElement.classList.add('dark');
document.documentElement.style.backgroundColor = '#1f2329';
localStorage.setItem('theme', 'dark');
setIsDark(true);
}

View File

@@ -40,8 +40,11 @@ const isDev = import.meta.env.DEV;
const preferiraTamno = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (lokalnaTema === 'dark' || (!lokalnaTema && preferiraTamno)) {
document.documentElement.classList.add('dark');
// Postavi pozadinu direktno na html elementu kako bi bila dostupna i prije učitavanja CSS-a
document.documentElement.style.backgroundColor = '#1f2329';
} else {
document.documentElement.classList.remove('dark');
document.documentElement.style.backgroundColor = '#ffffff';
}
}
primijeniTemu();
@@ -49,14 +52,14 @@ const isDev = import.meta.env.DEV;
</script>
</head>
<body class="relative min-h-screen bg-canvas-base text-text-main font-sans antialiased transition-colors duration-300">
<body class="relative min-h-screen bg-canvas-base text-text-main font-sans antialiased">
<div class="absolute inset-0 bg-[linear-gradient(to_right,var(--color-text-main)_0.03_1px,transparent_1px),linear-gradient(to_bottom,var(--color-text-main)_0.03_1px,transparent_1px)] bg-[size:64px_64px] opacity-[0.4] dark:opacity-[0.02] pointer-events-none z-0"></div>
{!isAuthPage && <NetworkGuard client:only="preact" />}
<Toast client:only="preact" />
{!isAuthPage && <NetworkGuard client:only="preact" transition:persist="network-guard" />}
<Toast client:only="preact" transition:persist="toast" />
{!isAuthPage && isDev && <ToastTrigger client:load />}
{!isAuthPage && <Navbar client:load minimal={minimalNav} />}
{!isAuthPage && <TaskCalendarPortal client:only="preact" />}
{!isAuthPage && <Navbar client:only="preact" transition:persist="navbar" minimal={minimalNav} />}
{!isAuthPage && <TaskCalendarPortal client:only="preact" transition:persist="calendar-portal" />}
<div id="main-content" class="relative z-10 flex flex-col min-h-screen max-w-6xl mx-auto px-6 md:px-8 py-6">
<header class="flex flex-row justify-between items-end pb-6 mb-8 border-b border-border-hairline">
@@ -68,7 +71,7 @@ const isDev = import.meta.env.DEV;
</div>
</header>
<main class="flex-1 w-full" transition:animate="fade">
<main class="flex-1 w-full">
<slot />
</main>

View File

@@ -21,6 +21,10 @@ export const $serviceRecords = atom([]);
export const $offlineQueueCount = atom(0);
export const $syncLog = atom([]);
// Timestamp posljednjeg uspješnog fetch-a — koristi se za TTL provjeru
let _lastDashboardFetchAt = 0;
const DASHBOARD_FETCH_TTL_MS = 30_000; // 30 sekundi
let dbPromise = null;
let syncListenerStarted = false;
let isSyncInProgress = false;
@@ -488,7 +492,13 @@ export async function ensureVehiclesCatalog() {
}
export async function fetchFleetDashboardData(options = {}) {
const { silent = false } = options;
const { silent = false, force = false } = options;
// Preskoči fetch ako su podaci svježi (unutar TTL), osim ako je force=true
if (!force && $workOrders.get().length > 0 && Date.now() - _lastDashboardFetchAt < DASHBOARD_FETCH_TTL_MS) {
return;
}
startOfflineSyncListeners();
if (!silent) $dashboardLoading.set(true);
$dashboardError.set(null);
@@ -511,6 +521,7 @@ export async function fetchFleetDashboardData(options = {}) {
$workOrders.set(Array.isArray(workOrders) ? workOrders : []);
$serviceRecords.set(Array.isArray(serviceRecords) ? serviceRecords : []);
$vehicles.set(normalizeCraneList(vehicles));
_lastDashboardFetchAt = Date.now();
await persistDashboardCache();
await processOfflineQueue();
} catch (error) {

View File

@@ -8,6 +8,9 @@ export const $tasksLoading = atom(false);
export const $taskTemplates = atom([]);
export const $taskTemplatesLoading = atom(false);
let _lastTaskFetchAt = 0;
const TASK_FETCH_TTL_MS = 30_000; // 30 sekundi
const STATUS_LABELS = {
aktivan: 'Aktivan',
servis: 'U tijeku',
@@ -25,11 +28,15 @@ export function isTaskActive(task) {
return status === 'aktivan' || status === 'servis' || status === 'spreman_za_zavrsetak';
}
export async function fetchTasks() {
export async function fetchTasks({ force = false } = {}) {
if (!force && $tasks.get().length > 0 && Date.now() - _lastTaskFetchAt < TASK_FETCH_TTL_MS) {
return;
}
$tasksLoading.set(true);
try {
const data = await api.get('tasks/tasks/');
$tasks.set(Array.isArray(data) ? data : (data?.results ?? []));
_lastTaskFetchAt = Date.now();
} catch (error) {
if (error?.status !== 401) {
showToast('Greška pri dohvatu zadataka.', 'error');

View File

@@ -59,3 +59,16 @@
body.modal-open #main-content {
filter: blur(4px);
}
/* View transitions — stara stranica ostaje vidljiva dok nova ne fade-ina, bez bijelog flash-a */
::view-transition-old(root) {
animation: none;
}
::view-transition-new(root) {
animation: 150ms ease-out fade-in-page forwards;
}
@keyframes fade-in-page {
from { opacity: 0; }
to { opacity: 1; }
}