From f7a5d2673b4963b3b5b9cc70003cae13d63b4ff7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dalibor=20Markovi=C4=87?= Date: Sat, 20 Jun 2026 12:42:11 +0200 Subject: [PATCH] =?UTF-8?q?Tema:=20slider=20za=20brzinu=20animacije,=20zam?= =?UTF-8?q?ena=20scaleIn=20sa=20blurIn,=20AJAX=20=C4=8Duvanje?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nova animacija blurIn (zamagljivanje) umesto scaleIn koji je izgledao isto kao fadeIn - slider za brzinu animacije (0.1s–0.8s, korak 0.1) premešten u karticu animacije - brzina i vrsta animacije čuvaju se jednim klikom, iz istog forma - nova kolona lokalna_brzina_animacije u bazi (migracija 056) - AJAX čuvanje profil/tema: nema reload stranice, scroll ostaje, toast notifikacija - otpremnica vidljiva samo za status Završeno/Preuzeto; radni nalog skriven kada završeno - toast notifikacije sa punom bojom pozadine (svetla i tamna tema) --- cmd/ntech/main.go | 1 + internal/db/repository.go | 1 + internal/db/sqlite/korisnici.go | 28 +++++- internal/handler/handler.go | 1 + internal/handler/profil.go | 102 ++++++++++++++------- internal/model/korisnik.go | 1 + internal/model/stranica.go | 1 + migrations/056_brzina_animacije.sql | 1 + web/static/css/main.css | 40 +++++--- web/static/css/teme/tamna.css | 3 + web/templates/stranice/profil_tema.html | 47 ++++++++-- web/templates/stranice/servis_detalji.html | 4 + web/templates/teme/podrazumevana/base.html | 58 +++++++++++- 13 files changed, 220 insertions(+), 68 deletions(-) create mode 100644 migrations/056_brzina_animacije.sql diff --git a/cmd/ntech/main.go b/cmd/ntech/main.go index 4dc0b89..cf39326 100644 --- a/cmd/ntech/main.go +++ b/cmd/ntech/main.go @@ -393,6 +393,7 @@ func main() { r.With(doz("tema.lokalno")).Post("/profil/tema", h.SacuvajLokalnuTemu) r.With(doz("tema.lokalno")).Post("/profil/animacija", h.SacuvajLokalnuAnimaciju) r.With(doz("tema.lokalno")).Post("/profil/hover", h.SacuvajLokalniHover) + r.With(doz("tema.lokalno")).Post("/profil/brzina-animacije", h.SacuvajLokalnuBrzinuAnimacije) r.Get("/profil/tema", h.ProfilTema) r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu) r.With(doz("tema.lokalno")).Post("/profil/pozadina/ukloni", h.ProfilUkloniPozadinu) diff --git a/internal/db/repository.go b/internal/db/repository.go index 3fceb26..71b7f07 100644 --- a/internal/db/repository.go +++ b/internal/db/repository.go @@ -156,6 +156,7 @@ type KorisniciRepository interface { SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine, glassOpacity string) error SacuvajLokalnuAnimaciju(ctx context.Context, id int64, animacija string) error SacuvajLokalniHover(ctx context.Context, id int64, hover string) error + SacuvajLokalnuBrzinuAnimacije(ctx context.Context, id int64, brzina string) error SacuvajAvatar(ctx context.Context, id int64, putanja string) error PostojiIjedan(ctx context.Context) (bool, error) Obrisi(ctx context.Context, id int64) error diff --git a/internal/db/sqlite/korisnici.go b/internal/db/sqlite/korisnici.go index 01fb7fa..a2f7a8c 100644 --- a/internal/db/sqlite/korisnici.go +++ b/internal/db/sqlite/korisnici.go @@ -32,6 +32,7 @@ type korisnikOpcije struct { avatarPutanja sql.NullString lokalnaAnimacija sql.NullString lokalniHover sql.NullString + lokalnaBrzinaAnimacije sql.NullString } // dodeliOpcijeKorisnika prenosi vrednosti iz korisnikOpcije na model.Korisnik @@ -48,6 +49,7 @@ func dodeliOpcijeKorisnika(k *model.Korisnik, o korisnikOpcije) { k.AvatarPutanja = o.avatarPutanja.String k.LokalnaAnimacija = o.lokalnaAnimacija.String k.LokalniHover = o.lokalniHover.String + k.LokalnaBrzinaAnimacije = o.lokalnaBrzinaAnimacije.String } // skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik @@ -59,7 +61,7 @@ func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, er &o.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja, &o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur, &o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja, - &o.lokalnaAnimacija, &o.lokalniHover, + &o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije, ); err != nil { return nil, err } @@ -104,7 +106,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'), COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), - COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, '') + COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''), + COALESCE(lokalna_brzina_animacije, '') FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme) k, err := skeniraiKorisnika(row) if err != nil { @@ -121,7 +124,8 @@ func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'), COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), - COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, '') + COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''), + COALESCE(lokalna_brzina_animacije, '') FROM korisnici WHERE id = ?`, id) k, err := skeniraiKorisnika(row) if err != nil { @@ -138,7 +142,8 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro COALESCE(lokalna_pozadina, ''), COALESCE(lokalna_pozadina_opacity, '50'), COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), - COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, '') + COALESCE(lokalna_animacija, ''), COALESCE(lokalni_hover, ''), + COALESCE(lokalna_brzina_animacije, '') FROM korisnici ORDER BY datum_kreiranja ASC`) if err != nil { return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) @@ -153,7 +158,7 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro &o.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja, &o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur, &o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja, - &o.lokalnaAnimacija, &o.lokalniHover, + &o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije, ); err != nil { return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) } @@ -229,6 +234,19 @@ func (r *sqliteKorisniciRepo) SacuvajLokalniHover(ctx context.Context, id int64, return nil } +func (r *sqliteKorisniciRepo) SacuvajLokalnuBrzinuAnimacije(ctx context.Context, id int64, brzina string) error { + var val any + if brzina != "" { + val = brzina + } + _, err := r.db.ExecContext(ctx, + `UPDATE korisnici SET lokalna_brzina_animacije = ? WHERE id = ?`, val, id) + if err != nil { + return fmt.Errorf("ntech: korisnici.SacuvajLokalnuBrzinuAnimacije: %w", err) + } + return nil +} + func (r *sqliteKorisniciRepo) AzurirajUlogu(ctx context.Context, id int64, uloga string) error { _, err := r.db.ExecContext(ctx, `UPDATE korisnici SET uloga = ? WHERE id = ?`, uloga, id) if err != nil { diff --git a/internal/handler/handler.go b/internal/handler/handler.go index bf06032..6dcf5fa 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -215,6 +215,7 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s if korisnik != nil { ps.LokalnaAnimacija = korisnik.LokalnaAnimacija ps.LokalniHover = korisnik.LokalniHover + ps.LokalnaBrzinaAnimacije = korisnik.LokalnaBrzinaAnimacije } return ps diff --git a/internal/handler/profil.go b/internal/handler/profil.go index b00cdaa..f18fc1a 100644 --- a/internal/handler/profil.go +++ b/internal/handler/profil.go @@ -15,6 +15,22 @@ import ( "ntech/internal/middleware" ) +// sacuvanoReferer vraća URL za redirect posle uspešnog čuvanja: +// koristi Referer zaglavlje (samo putanja, nikad spoljni sajt) i dodaje ?sacuvano=1. +// AJAX handler u browseru detektuje sacuvano=1 i prikazuje toast bez reloada. +func sacuvanoReferer(r *http.Request, rezervna string) string { + if ref := r.Referer(); ref != "" { + if u, err := url.Parse(ref); err == nil { + putanja := u.RequestURI() + if strings.Contains(putanja, "?") { + return putanja + "&sacuvano=1" + } + return putanja + "?sacuvano=1" + } + } + return rezervna + "?sacuvano=1" +} + // ProfilTema prikazuje stranicu lične teme i pozadine func (h *Handler) ProfilTema(w http.ResponseWriter, r *http.Request) { k := middleware.KorisnikIzKonteksta(r.Context()) @@ -177,8 +193,7 @@ func (h *Handler) ProfilOtpremiPozadinu(w http.ResponseWriter, r *http.Request) return } - middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uspešno otpremljena.") - http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther) } // ProfilUkloniPozadinu briše ličnu pozadinsku sliku korisnika @@ -202,8 +217,7 @@ func (h *Handler) ProfilUkloniPozadinu(w http.ResponseWriter, r *http.Request) { return } - middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.") - http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther) } // ProfilSacuvajPozadinuStilove čuva opacity i blur lične pozadine @@ -250,8 +264,7 @@ func (h *Handler) ProfilSacuvajPozadinuStilove(w http.ResponseWriter, r *http.Re return } - middleware.SetFlash(w, r, h.DB, "uspeh", "Podešavanja su sačuvana.") - http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther) } // SacuvajLokalnuTemu čuva korisnikovu lokalnu temu @@ -284,15 +297,7 @@ func (h *Handler) SacuvajLokalnuTemu(w http.ResponseWriter, r *http.Request) { return } - middleware.SetFlash(w, r, h.DB, "uspeh", "Tema je sačuvana.") - // koristimo samo putanju iz Referer zaglavlja — nikad ceo URL jer može biti spoljni sajt - if ref := r.Referer(); ref != "" { - if u, err := url.Parse(ref); err == nil { - http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther) - return - } - } - http.Redirect(w, r, "/admin/profil", http.StatusSeeOther) + http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil"), http.StatusSeeOther) } // SacuvajLokalnuAnimaciju čuva korisnikovu preferencu animacije tabela @@ -313,25 +318,29 @@ func (h *Handler) SacuvajLokalnuAnimaciju(w http.ResponseWriter, r *http.Request } animacija := r.FormValue("lokalna_animacija") - dozvoljene := map[string]bool{"": true, "bez": true, "fadeInUp": true, "fadeIn": true, "scaleIn": true, "slideLeft": true} + dozvoljene := map[string]bool{"": true, "bez": true, "fadeInUp": true, "fadeIn": true, "blurIn": true, "slideLeft": true} if !dozvoljene[animacija] { animacija = "" } + brzina := r.FormValue("lokalna_brzina_animacije") + dozvoljenieBrzine := map[string]bool{"": true, "0.1": true, "0.2": true, "0.3": true, "0.4": true, "0.5": true, "0.6": true, "0.7": true, "0.8": true} + if !dozvoljenieBrzine[brzina] { + brzina = "" + } + if err := h.KorisniciRepo.SacuvajLokalnuAnimaciju(r.Context(), k.ID, animacija); err != nil { middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.") http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther) return } - - middleware.SetFlash(w, r, h.DB, "uspeh", "Animacija je sačuvana.") - if ref := r.Referer(); ref != "" { - if u, err := url.Parse(ref); err == nil { - http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther) - return - } + if err := h.KorisniciRepo.SacuvajLokalnuBrzinuAnimacije(r.Context(), k.ID, brzina); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.") + http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther) + return } - http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther) + + http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther) } // SacuvajLokalniHover čuva korisnikovu preferencu hover efekta na karticama @@ -363,14 +372,39 @@ func (h *Handler) SacuvajLokalniHover(w http.ResponseWriter, r *http.Request) { return } - middleware.SetFlash(w, r, h.DB, "uspeh", "Hover efekat je sačuvan.") - if ref := r.Referer(); ref != "" { - if u, err := url.Parse(ref); err == nil { - http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther) - return - } + http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther) +} + +// SacuvajLokalnuBrzinuAnimacije čuva korisnikovu preferencu brzine animacije tabela +func (h *Handler) SacuvajLokalnuBrzinuAnimacije(w http.ResponseWriter, r *http.Request) { + k := middleware.KorisnikIzKonteksta(r.Context()) + if k == nil { + http.Redirect(w, r, "/prijava", http.StatusSeeOther) + return } - http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther) + if !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "tema.lokalno") { + http.Error(w, "Pristup odbijen", http.StatusForbidden) + return + } + if err := r.ParseForm(); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Greška. Pokušajte ponovo.") + http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther) + return + } + + brzina := r.FormValue("lokalna_brzina_animacije") + dozvoljene := map[string]bool{"": true, "0.2": true, "0.4": true, "0.6": true, "0.8": true, "1.0": true, "1.2": true, "1.5": true} + if !dozvoljene[brzina] { + brzina = "" + } + + if err := h.KorisniciRepo.SacuvajLokalnuBrzinuAnimacije(r.Context(), k.ID, brzina); err != nil { + middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.") + http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther) + return + } + + http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther) } // ProfilOtpremiAvatar prima upload lične avatar slike korisnika @@ -464,8 +498,7 @@ func (h *Handler) ProfilOtpremiAvatar(w http.ResponseWriter, r *http.Request) { return } - middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je sačuvan.") - http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther) } // ProfilUkloniAvatar briše ličnu avatar sliku korisnika @@ -489,6 +522,5 @@ func (h *Handler) ProfilUkloniAvatar(w http.ResponseWriter, r *http.Request) { return } - middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je uklonjen.") - http.Redirect(w, r, "/profil/tema", http.StatusSeeOther) + http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther) } diff --git a/internal/model/korisnik.go b/internal/model/korisnik.go index b572348..4b23cf8 100644 --- a/internal/model/korisnik.go +++ b/internal/model/korisnik.go @@ -21,6 +21,7 @@ type Korisnik struct { AvatarPutanja string LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft" LokalniHover string // "" | "bez" | "podizanje" | "svetlost" + LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | "0.6" | "0.8" | "1.2" (sekunde) } // Sesija predstavlja aktivnu sesiju prijavljenog korisnika diff --git a/internal/model/stranica.go b/internal/model/stranica.go index b1fee7e..8307e77 100644 --- a/internal/model/stranica.go +++ b/internal/model/stranica.go @@ -55,6 +55,7 @@ type PodaciStranice struct { AppPozadinaGlassOpacity string // vrednost 0-80 (% zatamnjivanje glass elemenata) — samo za ličnu pozadinu LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft" LokalniHover string // "" | "bez" | "podizanje" | "svetlost" + LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | ... | "1.5" (sekunde) } // PodaciDashboarda su podaci specifični za dashboard stranicu diff --git a/migrations/056_brzina_animacije.sql b/migrations/056_brzina_animacije.sql new file mode 100644 index 0000000..3ac9f0d --- /dev/null +++ b/migrations/056_brzina_animacije.sql @@ -0,0 +1 @@ +ALTER TABLE korisnici ADD COLUMN lokalna_brzina_animacije TEXT NOT NULL DEFAULT ''; diff --git a/web/static/css/main.css b/web/static/css/main.css index 1974eab..8eb2747 100644 --- a/web/static/css/main.css +++ b/web/static/css/main.css @@ -5,6 +5,16 @@ padding: 0; } +:root { --anim-trajanje: 0.4s; } +[data-brzina-animacije="0.1"] { --anim-trajanje: 0.1s; } +[data-brzina-animacije="0.2"] { --anim-trajanje: 0.2s; } +[data-brzina-animacije="0.3"] { --anim-trajanje: 0.3s; } +[data-brzina-animacije="0.4"] { --anim-trajanje: 0.4s; } +[data-brzina-animacije="0.5"] { --anim-trajanje: 0.5s; } +[data-brzina-animacije="0.6"] { --anim-trajanje: 0.6s; } +[data-brzina-animacije="0.7"] { --anim-trajanje: 0.7s; } +[data-brzina-animacije="0.8"] { --anim-trajanje: 0.8s; } + html { background: var(--pozadina); } @@ -793,8 +803,8 @@ select { animation: toastIn 0.3s ease forwards; max-width: 340px; } -.toast-greska { background: rgba(207, 87, 87, 0.12); color: var(--greska); border: 0.5px solid var(--greska); } -.toast-uspeh { background: var(--poruka-uspeh-bg); color: var(--poruka-uspeh-boja); border: 0.5px solid var(--poruka-uspeh-boja); } +.toast-greska { background: #fef2f2; color: #dc2626; border: 0.5px solid #fecaca; } +.toast-uspeh { background: #f0fdf4; color: #15803d; border: 0.5px solid #86efac; } .toast.nestaje { animation: toastOut 0.3s ease forwards; } @keyframes toastIn { from { opacity: 0; transform: translateY(12px); } @@ -1011,7 +1021,7 @@ select { /* animacije */ @keyframes fadeInUp { - from { opacity: 0; transform: translateY(10px); } + from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); } } @@ -1020,13 +1030,13 @@ select { to { opacity: 1; } } -@keyframes scaleIn { - from { opacity: 0; transform: scale(0.95); } - to { opacity: 1; transform: scale(1); } +@keyframes blurIn { + from { opacity: 0; filter: blur(8px); } + to { opacity: 1; filter: blur(0); } } @keyframes slideLeft { - from { opacity: 0; transform: translateX(-12px); } + from { opacity: 0; transform: translateX(-18px); } to { opacity: 1; transform: translateX(0); } } @@ -1050,7 +1060,7 @@ select { /* backwards (ne both): drži početni frame tokom stagger-delay-a da nema treperenja, ali NE zaključava krajnji transform — pa .kartica:hover lift radi i na .animiraj karticama */ .animiraj { - animation: fadeInUp 0.2s ease backwards; + animation: fadeInUp var(--anim-trajanje) ease backwards; } /* kartice ne animiramo po defaultu — animacija je samo na redovima tabela */ @@ -1066,26 +1076,26 @@ select { [data-animacija="fadeInUp"] .animiraj, [data-animacija="fadeInUp"] .tabela tbody tr, -[data-animacija="fadeInUp"] .kartica.animiraj { animation: fadeInUp 0.2s ease backwards; } +[data-animacija="fadeInUp"] .kartica.animiraj { animation: fadeInUp var(--anim-trajanje) ease backwards; } [data-animacija="fadeIn"] .animiraj, [data-animacija="fadeIn"] .tabela tbody tr, -[data-animacija="fadeIn"] .kartica.animiraj { animation: fadeIn 0.2s ease backwards; } +[data-animacija="fadeIn"] .kartica.animiraj { animation: fadeIn var(--anim-trajanje) ease backwards; } -[data-animacija="scaleIn"] .animiraj, -[data-animacija="scaleIn"] .tabela tbody tr, -[data-animacija="scaleIn"] .kartica.animiraj { animation: scaleIn 0.2s ease backwards; } +[data-animacija="blurIn"] .animiraj, +[data-animacija="blurIn"] .tabela tbody tr, +[data-animacija="blurIn"] .kartica.animiraj { animation: blurIn var(--anim-trajanje) ease backwards; } [data-animacija="slideLeft"] .animiraj, [data-animacija="slideLeft"] .tabela tbody tr, -[data-animacija="slideLeft"] .kartica.animiraj { animation: slideLeft 0.2s ease backwards; } +[data-animacija="slideLeft"] .kartica.animiraj { animation: slideLeft var(--anim-trajanje) ease backwards; } /* Stepenasta (stagger) animacija redova u svim listama — JEDNO mesto za sve tabele. Animacija se primenjuje direktno na u .tabela, bez potrebe za .animiraj klasom. Mora biti u main.css: HTMX navigacija (hx-select) odbacuje , pa stilovi iz page