Mobile UX: navbar dropdown, compact KPI, table scrolling, remove test toast
1. Navbar mobile dropdown
- Fixed potential clipping: added z-50 and top-full to dropdown panel
- Added overflow-visible to nav wrapper so dropdown is not cut off
- Wider panel (min-w-52), larger tap targets (py-2.5 per link)
- Active route indicated by colored dot + bold text
- Trigger button now shows hamburger icon + page name
2. KPI cards (section-dashboard)
- grid-cols-2 on mobile (was single column), sm:3, xl:5
- Reduced padding px-3 py-2.5 (was p-4) and font text-xl (was text-2xl)
- flex-col gap-0.5 layout for tighter vertical rhythm
3. Test Toast removed
- Deleted ToastTrigger.jsx component entirely
- Removed import and conditional render from Layout.astro
- Removed unused isDev variable from Layout.astro
4. AnimatedDataTable improvements
- Replaced plain-text loading placeholder with animated skeleton rows
(pulse animation with variable-width grey bars per column)
- Added horizontal scroll fade indicators: left/right gradient overlays
appear automatically via ResizeObserver + scroll event listener
- scroll-smooth touch scrolling on mobile (WebkitOverflowScrolling, scrollbarWidth)
- Added scope=col on th elements and aria-label / aria-busy props
- Slightly snappier spring config (tension 260, friction 28)
- leave animation reduced to 4px shift (was 8px) for less jarring removal
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -92,7 +92,7 @@ export default function Navbar({ minimal = false }) {
|
||||
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">
|
||||
<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 overflow-visible">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{/* Logo + korisnik */}
|
||||
<a href="/" className="flex flex-col hover:opacity-80">
|
||||
@@ -135,10 +135,11 @@ export default function Navbar({ minimal = false }) {
|
||||
<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'} ▾
|
||||
<summary className="list-none cursor-pointer rounded-lg border border-border-hairline bg-canvas-base px-3 py-1.5 text-xs font-medium text-text-main hover:bg-canvas-deep select-none">
|
||||
☰ {currentItem?.label || 'Izbornik'}
|
||||
</summary>
|
||||
<div className="absolute right-0 mt-2 min-w-44 rounded-lg border border-border-hairline bg-canvas-elevated shadow-lg">
|
||||
{/* Dropdown je positioniran fixed-like kroz visoki z-index kako ne bi bio odsječen */}
|
||||
<div className="absolute right-0 top-full mt-1 z-50 min-w-52 rounded-lg border border-border-hairline bg-canvas-elevated shadow-xl">
|
||||
<div className="border-b border-border-hairline px-3 py-2 text-[11px] uppercase tracking-wide text-text-muted">
|
||||
Navigacija
|
||||
</div>
|
||||
@@ -149,10 +150,11 @@ export default function Navbar({ minimal = false }) {
|
||||
href={item.href}
|
||||
className={
|
||||
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'
|
||||
? 'flex items-center gap-2 bg-indigo-50 px-3 py-2.5 text-sm font-medium text-indigo-700'
|
||||
: 'flex items-center gap-2 px-3 py-2.5 text-sm text-text-main hover:bg-canvas-deep'
|
||||
}
|
||||
>
|
||||
{pathname === item.href && <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />}
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { showToast } from '../stores/toastStore';
|
||||
|
||||
export default function ToastTrigger() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showToast('Sustav je spreman za rad.', 'info', 2500)}
|
||||
className="fixed bottom-4 right-4 z-50 rounded-full border border-border-hairline bg-canvas-elevated px-4 py-2 text-xs font-semibold text-text-main shadow-sm hover:bg-canvas-deep"
|
||||
>
|
||||
Test Toast
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -786,58 +786,58 @@ export default function FleetDashboardShell({ pageMode = 'dashboard' }) {
|
||||
)}
|
||||
|
||||
{isDashboardPage && (
|
||||
<div id="section-dashboard" className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{/* Otvoreni nalozi — klik vodi na /putni-nalozi */}
|
||||
<div id="section-dashboard" className="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
|
||||
{/* Otvoreni nalozi */}
|
||||
<a
|
||||
href="/putni-nalozi"
|
||||
className="group rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
|
||||
className="group flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
|
||||
>
|
||||
<p className="text-sm text-text-muted group-hover:text-indigo-700">Otvoreni nalozi</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.openWorkOrders}</p>
|
||||
<p className="text-xs text-text-muted group-hover:text-indigo-700">Otvoreni nalozi</p>
|
||||
<p className="text-xl font-semibold text-text-main">{stats.openWorkOrders}</p>
|
||||
{hydratedSelectedVehicleId && contextStats !== null && (
|
||||
<p className="mt-1 text-xs text-indigo-600">{contextStats.openWorkOrders} u kontekstu</p>
|
||||
<p className="text-[10px] text-indigo-600">{contextStats.openWorkOrders} u kontekstu</p>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Servisi danas — klik vodi na /servisni-zapisi */}
|
||||
{/* Servisi danas */}
|
||||
<a
|
||||
href="/servisni-zapisi"
|
||||
className="group rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
|
||||
className="group flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm transition-all hover:border-indigo-300 hover:bg-indigo-50/60 hover:shadow-md"
|
||||
>
|
||||
<p className="text-sm text-text-muted group-hover:text-indigo-700">Servisi danas</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-text-main">{stats.servicesToday}</p>
|
||||
<p className="text-xs text-text-muted group-hover:text-indigo-700">Servisi danas</p>
|
||||
<p className="text-xl font-semibold text-text-main">{stats.servicesToday}</p>
|
||||
{hydratedSelectedVehicleId && contextStats !== null && (
|
||||
<p className="mt-1 text-xs text-indigo-600">{contextStats.servicesToday} u kontekstu</p>
|
||||
<p className="text-[10px] text-indigo-600">{contextStats.servicesToday} u kontekstu</p>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{/* Aktivni zadaci bez putnog naloga */}
|
||||
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
||||
<p className="text-sm text-text-muted">Bez putnog naloga</p>
|
||||
<p className={`mt-2 text-2xl font-semibold ${stats.tasksWithoutWorkOrder > 0 ? 'text-amber-600' : 'text-text-main'}`}>
|
||||
{/* Bez putnog naloga */}
|
||||
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
|
||||
<p className="text-xs text-text-muted">Bez put. naloga</p>
|
||||
<p className={`text-xl font-semibold ${stats.tasksWithoutWorkOrder > 0 ? 'text-amber-600' : 'text-text-main'}`}>
|
||||
{stats.tasksWithoutWorkOrder}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">aktivnih zadataka</p>
|
||||
<p className="text-[10px] text-text-muted">aktivnih zadataka</p>
|
||||
</article>
|
||||
|
||||
{/* Dovršeni nalozi s postotkom */}
|
||||
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
||||
<p className="text-sm text-text-muted">Dovršeni nalozi</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-emerald-600">{stats.closedWorkOrders}</p>
|
||||
{/* Dovršeni nalozi */}
|
||||
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
|
||||
<p className="text-xs text-text-muted">Dovršeni nalozi</p>
|
||||
<p className="text-xl font-semibold text-emerald-600">{stats.closedWorkOrders}</p>
|
||||
{stats.totalWorkOrders > 0 && (
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{Math.round((stats.closedWorkOrders / stats.totalWorkOrders) * 100)}% od ukupno {stats.totalWorkOrders}
|
||||
<p className="text-[10px] text-text-muted">
|
||||
{Math.round((stats.closedWorkOrders / stats.totalWorkOrders) * 100)}% / {stats.totalWorkOrders} ukupno
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
|
||||
{/* Moji zadaci danas */}
|
||||
<article className="rounded-xl border border-border-hairline bg-canvas-elevated p-4 shadow-sm">
|
||||
<p className="text-sm text-text-muted">Moji zadaci danas</p>
|
||||
<p className={`mt-2 text-2xl font-semibold ${myTasksToday > 0 ? 'text-indigo-600' : 'text-text-main'}`}>
|
||||
<article className="flex flex-col gap-0.5 rounded-xl border border-border-hairline bg-canvas-elevated px-3 py-2.5 shadow-sm">
|
||||
<p className="text-xs text-text-muted">Moji danas</p>
|
||||
<p className={`text-xl font-semibold ${myTasksToday > 0 ? 'text-indigo-600' : 'text-text-main'}`}>
|
||||
{hasMounted ? myTasksToday : '—'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-text-muted">planirani na mene</p>
|
||||
<p className="text-[10px] text-text-muted">planirani na mene</p>
|
||||
</article>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
function SkeletonRows({ columns, rows = 4 }) {
|
||||
return Array.from({ length: rows }).map((_, rowIdx) => (
|
||||
<tr key={rowIdx} className="animate-pulse">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={col.className}>
|
||||
<div className="h-3 rounded bg-canvas-deep" style={{ width: `${55 + ((rowIdx * 17 + col.key.length * 7) % 30)}%` }} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
));
|
||||
}
|
||||
|
||||
export default function AnimatedDataTable({
|
||||
columns = [],
|
||||
@@ -14,17 +26,43 @@ export default function AnimatedDataTable({
|
||||
bodyClassName = 'divide-y divide-border-hairline',
|
||||
rowClassName = 'hover:bg-canvas-deep',
|
||||
isRowSelected = () => false,
|
||||
wrapperClassName = 'overflow-x-auto',
|
||||
wrapperClassName = '',
|
||||
trail = 35,
|
||||
ariaLabel,
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const scrollRef = useRef(null);
|
||||
const [scrollState, setScrollState] = useState({ left: false, right: false });
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Prati scroll poziciju da bi se prikazali fade indikatori s lijeva/desna
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
function updateScrollState() {
|
||||
setScrollState({
|
||||
left: el.scrollLeft > 4,
|
||||
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 4,
|
||||
});
|
||||
}
|
||||
|
||||
updateScrollState();
|
||||
el.addEventListener('scroll', updateScrollState, { passive: true });
|
||||
const ro = new ResizeObserver(updateScrollState);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', updateScrollState);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [mounted, rows.length]);
|
||||
|
||||
const transitions = useTransition(rows, {
|
||||
keys: (row) => rowKey(row),
|
||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
from: { opacity: 0, transform: 'translate3d(0,6px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
enter: (row) => ({
|
||||
opacity: 1,
|
||||
transform: 'translate3d(0,0,0)',
|
||||
@@ -37,43 +75,55 @@ export default function AnimatedDataTable({
|
||||
? 'inset 0 0 0 2px rgba(16,185,129,0.75)'
|
||||
: 'inset 0 0 0 0px rgba(16,185,129,0)',
|
||||
}),
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-4px,0)', boxShadow: 'inset 0 0 0 0px rgba(16,185,129,0)' },
|
||||
trail,
|
||||
config: { tension: 230, friction: 26 },
|
||||
config: { tension: 260, friction: 28 },
|
||||
});
|
||||
|
||||
const colSpan = Math.max(1, columns.length);
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<table className={tableClassName}>
|
||||
<div className={`relative ${wrapperClassName}`}>
|
||||
{/* Fade gradijent — lijeva strana (kad se može scrollati ulijevo) */}
|
||||
{scrollState.left && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-canvas-elevated to-transparent"
|
||||
/>
|
||||
)}
|
||||
{/* Fade gradijent — desna strana (kad postoji sadržaj desno) */}
|
||||
{scrollState.right && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-canvas-elevated to-transparent"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-x-auto"
|
||||
style={{ WebkitOverflowScrolling: 'touch', scrollbarWidth: 'thin' }}
|
||||
>
|
||||
<table
|
||||
className={tableClassName}
|
||||
aria-label={ariaLabel}
|
||||
aria-busy={loading ? 'true' : undefined}
|
||||
>
|
||||
<thead className={headClassName}>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} className={column.className}>
|
||||
<th key={column.key} scope="col" className={column.className}>
|
||||
{column.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={bodyClassName}>
|
||||
{!mounted && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
{loadingMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{mounted && loading && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
{loadingMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!mounted && <SkeletonRows columns={columns} rows={3} />}
|
||||
{mounted && loading && <SkeletonRows columns={columns} rows={3} />}
|
||||
{mounted && !loading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
<td colSpan={colSpan} className="px-4 py-8 text-center text-sm text-text-muted">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -90,5 +140,6 @@ export default function AnimatedDataTable({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import '../styles/global.css';
|
||||
import ThemeToggle from '../components/ui/ThemeToggle.jsx';
|
||||
import ButtonDisplayCounter from '../components/atom/ButtonDisplayCounter.jsx';
|
||||
import Toast from '../components/ui/Toast.jsx';
|
||||
import ToastTrigger from '../components/ToastTrigger.jsx';
|
||||
import NetworkGuard from '../components/ui/NetworkGuard.jsx';
|
||||
import Navbar from '../components/Navbar.jsx';
|
||||
import TaskCalendarPortal from '../components/layout/TaskCalendarPortal.jsx';
|
||||
@@ -18,7 +17,6 @@ interface Props {
|
||||
|
||||
const { title, isAuthPage = false, minimalNav = false } = Astro.props as Props;
|
||||
const isProd = import.meta.env.PROD;
|
||||
const isDev = import.meta.env.DEV;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
@@ -57,7 +55,6 @@ const isDev = import.meta.env.DEV;
|
||||
|
||||
{!isAuthPage && <NetworkGuard client:only="preact" transition:persist="network-guard" />}
|
||||
<Toast client:only="preact" transition:persist="toast" />
|
||||
{!isAuthPage && isDev && <ToastTrigger client:load />}
|
||||
{!isAuthPage && <Navbar client:only="preact" transition:persist="navbar" minimal={minimalNav} />}
|
||||
{!isAuthPage && <TaskCalendarPortal client:only="preact" transition:persist="calendar-portal" />}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user