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 ThemeToggle from './ui/ThemeToggle';
|
||||||
import AuthWidget from './AuthWidget';
|
import AuthWidget from './AuthWidget';
|
||||||
import UserDisplay from './ui/UserDisplay';
|
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';
|
import { hydrateAuthFromStorage } from '../stores/authStore';
|
||||||
|
|
||||||
const ITEMS = [
|
const ITEMS = [
|
||||||
@@ -13,27 +14,82 @@ const ITEMS = [
|
|||||||
{ id: 'clients', label: 'Klijenti', href: '/clients' },
|
{ id: 'clients', label: 'Klijenti', href: '/clients' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar({ minimal = false }) {
|
function normalizePath(p) {
|
||||||
const [pathname, setPathname] = useState('/');
|
const value = String(p || '/');
|
||||||
const normalizedPath = useMemo(() => {
|
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
|
||||||
const value = String(pathname || '/');
|
return value;
|
||||||
if (value.length > 1 && value.endsWith('/')) return value.slice(0, -1);
|
}
|
||||||
return value;
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
|
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(() => {
|
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();
|
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(
|
// Kad se promijeni activeIndex (nova stranica), animiraj pill na novu poziciju
|
||||||
() => ITEMS.find((item) => item.href === normalizedPath) || ITEMS[0],
|
useEffect(() => {
|
||||||
[normalizedPath]
|
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 (
|
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">
|
<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" />}
|
{!minimal && <UserDisplay field="ime_prezime" className="text-xs text-text-muted" />}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Navigacijski linkovi */}
|
{/* Navigacijski linkovi s react-spring klizačem */}
|
||||||
<ul className="hidden items-center gap-1 text-sm sm:flex">
|
<ul className="relative hidden items-center gap-1 text-sm sm:flex">
|
||||||
{ITEMS.map((item) => (
|
{/* Animirani pozadinski klizač */}
|
||||||
<li key={item.id}>
|
<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
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={
|
className={
|
||||||
normalizedPath === item.href
|
pathname === item.href
|
||||||
? 'rounded-lg bg-indigo-50 px-3 py-2 font-medium text-indigo-700'
|
? 'relative z-10 block rounded-lg px-3 py-2 font-medium text-indigo-700'
|
||||||
: 'rounded-lg px-3 py-2 text-text-main hover:bg-canvas-deep'
|
: 'relative z-10 block rounded-lg px-3 py-2 text-text-main hover:text-indigo-600'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -64,6 +133,7 @@ export default function Navbar({ minimal = false }) {
|
|||||||
|
|
||||||
{/* Desna strana */}
|
{/* Desna strana */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Mobilni dropdown */}
|
||||||
<details className="relative sm:hidden">
|
<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">
|
<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'} ▾
|
{currentItem?.label || 'Izbornik'} ▾
|
||||||
@@ -78,7 +148,7 @@ export default function Navbar({ minimal = false }) {
|
|||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className={
|
className={
|
||||||
normalizedPath === item.href
|
pathname === item.href
|
||||||
? 'block bg-indigo-50 px-3 py-2 text-sm font-medium text-indigo-700'
|
? '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'
|
: '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);
|
return () => window.removeEventListener('calendar:open-task', onCalendarOpenTask);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const nextSection = String(resolvedInitialSection || 'dashboard');
|
|
||||||
setActiveSection(nextSection);
|
|
||||||
}, [resolvedInitialSection]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const syncContextFromStorage = () => {
|
const syncContextFromStorage = () => {
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ export default function ThemeToggle() {
|
|||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
if (document.documentElement.classList.contains('dark')) {
|
if (document.documentElement.classList.contains('dark')) {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.documentElement.style.backgroundColor = '#ffffff';
|
||||||
localStorage.setItem('theme', 'light');
|
localStorage.setItem('theme', 'light');
|
||||||
setIsDark(false);
|
setIsDark(false);
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
|
document.documentElement.style.backgroundColor = '#1f2329';
|
||||||
localStorage.setItem('theme', 'dark');
|
localStorage.setItem('theme', 'dark');
|
||||||
setIsDark(true);
|
setIsDark(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,11 @@ const isDev = import.meta.env.DEV;
|
|||||||
const preferiraTamno = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
const preferiraTamno = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
if (lokalnaTema === 'dark' || (!lokalnaTema && preferiraTamno)) {
|
if (lokalnaTema === 'dark' || (!lokalnaTema && preferiraTamno)) {
|
||||||
document.documentElement.classList.add('dark');
|
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 {
|
} else {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
|
document.documentElement.style.backgroundColor = '#ffffff';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
primijeniTemu();
|
primijeniTemu();
|
||||||
@@ -49,14 +52,14 @@ const isDev = import.meta.env.DEV;
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</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>
|
<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" />}
|
{!isAuthPage && <NetworkGuard client:only="preact" transition:persist="network-guard" />}
|
||||||
<Toast client:only="preact" />
|
<Toast client:only="preact" transition:persist="toast" />
|
||||||
{!isAuthPage && isDev && <ToastTrigger client:load />}
|
{!isAuthPage && isDev && <ToastTrigger client:load />}
|
||||||
{!isAuthPage && <Navbar client:load minimal={minimalNav} />}
|
{!isAuthPage && <Navbar client:only="preact" transition:persist="navbar" minimal={minimalNav} />}
|
||||||
{!isAuthPage && <TaskCalendarPortal client:only="preact" />}
|
{!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">
|
<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">
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="flex-1 w-full" transition:animate="fade">
|
<main class="flex-1 w-full">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ export const $serviceRecords = atom([]);
|
|||||||
export const $offlineQueueCount = atom(0);
|
export const $offlineQueueCount = atom(0);
|
||||||
export const $syncLog = atom([]);
|
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 dbPromise = null;
|
||||||
let syncListenerStarted = false;
|
let syncListenerStarted = false;
|
||||||
let isSyncInProgress = false;
|
let isSyncInProgress = false;
|
||||||
@@ -488,7 +492,13 @@ export async function ensureVehiclesCatalog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchFleetDashboardData(options = {}) {
|
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();
|
startOfflineSyncListeners();
|
||||||
if (!silent) $dashboardLoading.set(true);
|
if (!silent) $dashboardLoading.set(true);
|
||||||
$dashboardError.set(null);
|
$dashboardError.set(null);
|
||||||
@@ -511,6 +521,7 @@ export async function fetchFleetDashboardData(options = {}) {
|
|||||||
$workOrders.set(Array.isArray(workOrders) ? workOrders : []);
|
$workOrders.set(Array.isArray(workOrders) ? workOrders : []);
|
||||||
$serviceRecords.set(Array.isArray(serviceRecords) ? serviceRecords : []);
|
$serviceRecords.set(Array.isArray(serviceRecords) ? serviceRecords : []);
|
||||||
$vehicles.set(normalizeCraneList(vehicles));
|
$vehicles.set(normalizeCraneList(vehicles));
|
||||||
|
_lastDashboardFetchAt = Date.now();
|
||||||
await persistDashboardCache();
|
await persistDashboardCache();
|
||||||
await processOfflineQueue();
|
await processOfflineQueue();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ export const $tasksLoading = atom(false);
|
|||||||
export const $taskTemplates = atom([]);
|
export const $taskTemplates = atom([]);
|
||||||
export const $taskTemplatesLoading = atom(false);
|
export const $taskTemplatesLoading = atom(false);
|
||||||
|
|
||||||
|
let _lastTaskFetchAt = 0;
|
||||||
|
const TASK_FETCH_TTL_MS = 30_000; // 30 sekundi
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
aktivan: 'Aktivan',
|
aktivan: 'Aktivan',
|
||||||
servis: 'U tijeku',
|
servis: 'U tijeku',
|
||||||
@@ -25,11 +28,15 @@ export function isTaskActive(task) {
|
|||||||
return status === 'aktivan' || status === 'servis' || status === 'spreman_za_zavrsetak';
|
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);
|
$tasksLoading.set(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.get('tasks/tasks/');
|
const data = await api.get('tasks/tasks/');
|
||||||
$tasks.set(Array.isArray(data) ? data : (data?.results ?? []));
|
$tasks.set(Array.isArray(data) ? data : (data?.results ?? []));
|
||||||
|
_lastTaskFetchAt = Date.now();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.status !== 401) {
|
if (error?.status !== 401) {
|
||||||
showToast('Greška pri dohvatu zadataka.', 'error');
|
showToast('Greška pri dohvatu zadataka.', 'error');
|
||||||
|
|||||||
@@ -58,4 +58,17 @@
|
|||||||
}
|
}
|
||||||
body.modal-open #main-content {
|
body.modal-open #main-content {
|
||||||
filter: blur(4px);
|
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