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:
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user