This commit is contained in:
82
frontend/src/components/ui/AnimatedDataTable.jsx
Normal file
82
frontend/src/components/ui/AnimatedDataTable.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { animated, useTransition } from '@react-spring/web';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
|
||||
export default function AnimatedDataTable({
|
||||
columns = [],
|
||||
rows = [],
|
||||
rowKey = (row) => row?.id,
|
||||
renderRow,
|
||||
loading = false,
|
||||
loadingMessage = 'Učitavanje...',
|
||||
emptyMessage = 'Nema podataka.',
|
||||
tableClassName = 'min-w-full text-sm',
|
||||
headClassName = 'bg-canvas-deep text-left text-xs uppercase tracking-wide text-text-muted',
|
||||
bodyClassName = 'divide-y divide-border-hairline',
|
||||
rowClassName = 'hover:bg-canvas-deep',
|
||||
wrapperClassName = 'overflow-x-auto',
|
||||
trail = 35,
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const transitions = useTransition(rows, {
|
||||
keys: (row) => rowKey(row),
|
||||
from: { opacity: 0, transform: 'translate3d(0,8px,0)' },
|
||||
enter: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||
leave: { opacity: 0, transform: 'translate3d(0,-8px,0)' },
|
||||
trail,
|
||||
config: { tension: 230, friction: 26 },
|
||||
});
|
||||
|
||||
const colSpan = Math.max(1, columns.length);
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<table className={tableClassName}>
|
||||
<thead className={headClassName}>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} 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 && !loading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-4 py-6 text-center text-sm text-text-muted">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{mounted && !loading && transitions((style, row) => (
|
||||
<animated.tr
|
||||
key={rowKey(row)}
|
||||
style={style}
|
||||
className={typeof rowClassName === 'function' ? rowClassName(row) : rowClassName}
|
||||
>
|
||||
{renderRow(row)}
|
||||
</animated.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/ui/AnimatedPage.jsx
Normal file
15
frontend/src/components/ui/AnimatedPage.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
|
||||
export default function AnimatedPage({ children, className = '' }) {
|
||||
const pageStyle = useSpring({
|
||||
from: { opacity: 0, transform: 'translate3d(0,10px,0)' },
|
||||
to: { opacity: 1, transform: 'translate3d(0,0,0)' },
|
||||
config: { tension: 210, friction: 24 },
|
||||
});
|
||||
|
||||
return (
|
||||
<animated.div style={pageStyle} className={className}>
|
||||
{children}
|
||||
</animated.div>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/ui/ModalShell.jsx
Normal file
51
frontend/src/components/ui/ModalShell.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { createPortal } from 'preact/compat';
|
||||
|
||||
function joinClasses(...classes) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function ModalShell({
|
||||
children,
|
||||
onClose,
|
||||
overlayClassName = '',
|
||||
contentClassName = 'flex min-h-full items-center justify-center',
|
||||
panelClassName = '',
|
||||
closeOnBackdrop = true,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const body = document.body;
|
||||
const count = parseInt(body.dataset.modalCount || '0', 10);
|
||||
body.dataset.modalCount = count + 1;
|
||||
body.classList.add('modal-open');
|
||||
return () => {
|
||||
const next = parseInt(body.dataset.modalCount || '0', 10) - 1;
|
||||
body.dataset.modalCount = next;
|
||||
if (next <= 0) {
|
||||
body.classList.remove('modal-open');
|
||||
delete body.dataset.modalCount;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOverlayClick = closeOnBackdrop
|
||||
? (event) => {
|
||||
event.stopPropagation();
|
||||
onClose?.(event);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={joinClasses('fixed inset-0', overlayClassName)}
|
||||
onClick={handleOverlayClick}
|
||||
>
|
||||
<div className={contentClassName}>
|
||||
<div className={panelClassName} onClick={(event) => event.stopPropagation()}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/ui/NetworkGuard.jsx
Normal file
20
frontend/src/components/ui/NetworkGuard.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { $isOffline } from '../../stores/networkStore';
|
||||
|
||||
export default function NetworkGuard() {
|
||||
const isOffline = useStore($isOffline);
|
||||
if (!isOffline) return null;
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed top-0 left-0 right-0 z-20 p-2 text-[11px] font-mono tracking-wider text-amber-300 bg-amber-500/10 border-b border-amber-500/20 backdrop-blur-md text-center"
|
||||
role="alert"
|
||||
>
|
||||
<span className="font-bold">STATUS_OFFLINE:</span>{' '}
|
||||
{isDev
|
||||
? 'Veza s Django API-jem (8001) je prekinuta. Podaci se čitaju iz lokalnog cachea.'
|
||||
: 'Veza je prekinuta. Podaci se čitaju iz lokalnog cachea.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/ui/ThemeToggle.jsx
Normal file
41
frontend/src/components/ui/ThemeToggle.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const imaDarkKlasu = document.documentElement.classList.contains('dark');
|
||||
setIsDark(imaDarkKlasu);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
setIsDark(false);
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
setIsDark(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
type="button"
|
||||
className="p-2.5 rounded-s-xl bg-canvas-elevated border-y border-s border-border-hairline text-text-muted hover:text-text-main transition-all duration-200 focus:outline-none cursor-pointer flex items-center justify-center"
|
||||
aria-label="Prebaci temu"
|
||||
>
|
||||
{isDark ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M12 7a5 5 0 100 10 5 5 0 000-10z"></path>
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.354 15.354A9 9 0 118.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
5
frontend/src/components/ui/Toast.jsx
Normal file
5
frontend/src/components/ui/Toast.jsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ToastContainer from '../ToastContainer';
|
||||
|
||||
export default function Toast() {
|
||||
return <ToastContainer />;
|
||||
}
|
||||
151
frontend/src/components/ui/UserDisplay.jsx
Normal file
151
frontend/src/components/ui/UserDisplay.jsx
Normal file
@@ -0,0 +1,151 @@
|
||||
// src/components/ui/UserDisplay.jsx
|
||||
//
|
||||
// Prikazuje podatke o korisniku direktno iz $user nanostores atoma.
|
||||
//
|
||||
// ARHITEKTONSKA ODLUKA — zašto store, ne prop:
|
||||
// • $user je reaktivan atom — svaka promjena (login, refresh profila)
|
||||
// automatski re-renderira sve UserDisplay instance bez prop-drillinga
|
||||
// • Komponenta se poziva na više mjesta (Topbar, Sidebar, Profil) —
|
||||
// proslijeđivanje user objekta kao prop-a kroz sve te slojeve je anti-pattern
|
||||
//
|
||||
// USAGE:
|
||||
// <UserDisplay field="ime_prezime" /> — "Ivo Horvat"
|
||||
// <UserDisplay field="ime" /> — "Ivo"
|
||||
// <UserDisplay field="prezime" /> — "Horvat"
|
||||
// <UserDisplay field="email" /> — "ivo@example.com"
|
||||
// <UserDisplay field="uloga" /> — "Serviser" | "Administrator" | "Korisnik"
|
||||
// <UserDisplay field="ime_prezime" variant="badge" /> — avatar krug + ime
|
||||
// <UserDisplay field="ime_prezime" variant="card" /> — puna kartica s ulogom
|
||||
|
||||
import { useStore } from '@nanostores/preact';
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { $user, $authReady } from '../../stores/authStore.js';
|
||||
|
||||
// ─── Mapa razrješivača polja ────────────────────────────────────────────────
|
||||
// Dodavanje novog polja = jedna nova linija ovdje.
|
||||
const FIELD_RESOLVER = {
|
||||
ime_prezime: (u) =>
|
||||
[`${u.first_name ?? ''}`.trim(), `${u.last_name ?? ''}`.trim()]
|
||||
.filter(Boolean)
|
||||
.join(' ') || u.email || '—',
|
||||
ime: (u) => u.first_name?.trim() || u.email || '—',
|
||||
prezime: (u) => u.last_name?.trim() || '—',
|
||||
email: (u) => u.email || '—',
|
||||
uloga: (u) => {
|
||||
if (u.is_staff) return 'Administrator';
|
||||
if (u.is_serviser) return 'Serviser';
|
||||
return 'Korisnik';
|
||||
},
|
||||
};
|
||||
|
||||
// Dohvati inicijale za avatar krug (max 2 slova)
|
||||
function getInitials(user) {
|
||||
if (!user) return '?';
|
||||
const first = user.first_name?.trim()?.[0] ?? '';
|
||||
const last = user.last_name?.trim()?.[0] ?? '';
|
||||
if (first || last) return `${first}${last}`.toUpperCase();
|
||||
return (user.email?.[0] ?? '?').toUpperCase();
|
||||
}
|
||||
|
||||
// ─── Varijante prikaza ──────────────────────────────────────────────────────
|
||||
|
||||
function TextVariant({ value, className }) {
|
||||
return (
|
||||
<span className={className ?? 'text-sm font-medium text-text-main'}>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeVariant({ user, value }) {
|
||||
// Kompaktni prikaz za navigacijske trake — krug s inicijalima + ime
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-[11px] font-bold text-white select-none">
|
||||
{getInitials(user)}
|
||||
</span>
|
||||
<span className="hidden text-sm font-medium text-text-main sm:inline">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardVariant({ user, value }) {
|
||||
// Prošireni prikaz — avatar + ime + uloga
|
||||
const role = FIELD_RESOLVER.uloga(user);
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-indigo-600 text-sm font-bold text-white select-none">
|
||||
{getInitials(user)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-text-main">{value}</p>
|
||||
<p className="text-[11px] text-text-muted">{role}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Skeleton za loading stanje — iste dimenzije kao sadržaj, bez layout shifta
|
||||
function Skeleton({ variant }) {
|
||||
if (variant === 'card') {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-canvas-deep animate-pulse" />
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-24 rounded bg-canvas-deep animate-pulse" />
|
||||
<div className="h-2.5 w-16 rounded bg-canvas-deep animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (variant === 'badge') {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-7 w-7 rounded-full bg-canvas-deep animate-pulse" />
|
||||
<div className="hidden h-3 w-20 rounded bg-canvas-deep animate-pulse sm:block" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className="h-3 w-20 rounded bg-canvas-deep animate-pulse" />;
|
||||
}
|
||||
|
||||
// ─── Glavna komponenta ──────────────────────────────────────────────────────
|
||||
|
||||
export default function UserDisplay({
|
||||
field = 'ime_prezime',
|
||||
variant = 'text',
|
||||
fallback = '—',
|
||||
className,
|
||||
}) {
|
||||
const user = useStore($user);
|
||||
const authReady = useStore($authReady);
|
||||
// SSR guard — na serveru uvijek renderira skeleton kako bi hydration bio miran
|
||||
const [hasMounted, setHasMounted] = useState(false);
|
||||
useEffect(() => { setHasMounted(true); }, []);
|
||||
|
||||
// Prije mount-a (SSR) i dok auth nije spreman — skeleton
|
||||
if (!hasMounted || !authReady) {
|
||||
return <Skeleton variant={variant} />;
|
||||
}
|
||||
|
||||
// Auth spreman ali user nije učitan (nije prijavljen)
|
||||
if (!user) {
|
||||
return variant === 'text'
|
||||
? <span className={className ?? 'text-sm text-text-muted'}>{fallback}</span>
|
||||
: <Skeleton variant={variant} />;
|
||||
}
|
||||
|
||||
const resolver = FIELD_RESOLVER[field];
|
||||
if (!resolver) {
|
||||
console.warn(`[UserDisplay] Nepoznato polje: "${field}". Dostupna polja: ${Object.keys(FIELD_RESOLVER).join(', ')}`);
|
||||
return <span className="text-sm text-red-500">?{field}</span>;
|
||||
}
|
||||
|
||||
const value = resolver(user);
|
||||
|
||||
if (variant === 'badge') return <BadgeVariant user={user} value={value} />;
|
||||
if (variant === 'card') return <CardVariant user={user} value={value} />;
|
||||
return <TextVariant value={value} className={className} />;
|
||||
}
|
||||
Reference in New Issue
Block a user