Tema: slider za brzinu animacije, zamena scaleIn sa blurIn, AJAX čuvanje

- 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)
This commit is contained in:
2026-06-20 12:42:11 +02:00
parent 880456a5ba
commit f7a5d2673b
13 changed files with 220 additions and 68 deletions
+1
View File
@@ -393,6 +393,7 @@ func main() {
r.With(doz("tema.lokalno")).Post("/profil/tema", h.SacuvajLokalnuTemu) 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/animacija", h.SacuvajLokalnuAnimaciju)
r.With(doz("tema.lokalno")).Post("/profil/hover", h.SacuvajLokalniHover) 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.Get("/profil/tema", h.ProfilTema)
r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu) r.With(doz("tema.lokalno")).Post("/profil/pozadina", h.ProfilOtpremiPozadinu)
r.With(doz("tema.lokalno")).Post("/profil/pozadina/ukloni", h.ProfilUkloniPozadinu) r.With(doz("tema.lokalno")).Post("/profil/pozadina/ukloni", h.ProfilUkloniPozadinu)
+1
View File
@@ -156,6 +156,7 @@ type KorisniciRepository interface {
SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine, glassOpacity string) error SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine, glassOpacity string) error
SacuvajLokalnuAnimaciju(ctx context.Context, id int64, animacija string) error SacuvajLokalnuAnimaciju(ctx context.Context, id int64, animacija string) error
SacuvajLokalniHover(ctx context.Context, id int64, hover 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 SacuvajAvatar(ctx context.Context, id int64, putanja string) error
PostojiIjedan(ctx context.Context) (bool, error) PostojiIjedan(ctx context.Context) (bool, error)
Obrisi(ctx context.Context, id int64) error Obrisi(ctx context.Context, id int64) error
+23 -5
View File
@@ -32,6 +32,7 @@ type korisnikOpcije struct {
avatarPutanja sql.NullString avatarPutanja sql.NullString
lokalnaAnimacija sql.NullString lokalnaAnimacija sql.NullString
lokalniHover sql.NullString lokalniHover sql.NullString
lokalnaBrzinaAnimacije sql.NullString
} }
// dodeliOpcijeKorisnika prenosi vrednosti iz korisnikOpcije na model.Korisnik // 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.AvatarPutanja = o.avatarPutanja.String
k.LokalnaAnimacija = o.lokalnaAnimacija.String k.LokalnaAnimacija = o.lokalnaAnimacija.String
k.LokalniHover = o.lokalniHover.String k.LokalniHover = o.lokalniHover.String
k.LokalnaBrzinaAnimacije = o.lokalnaBrzinaAnimacije.String
} }
// skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik // 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.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja,
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur, &o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja, &o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
); err != nil { ); err != nil {
return nil, err 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, ''), COALESCE(lokalna_pozadina_opacity, '50'),
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), 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) FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme)
k, err := skeniraiKorisnika(row) k, err := skeniraiKorisnika(row)
if err != nil { 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, ''), COALESCE(lokalna_pozadina_opacity, '50'),
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), 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) FROM korisnici WHERE id = ?`, id)
k, err := skeniraiKorisnika(row) k, err := skeniraiKorisnika(row)
if err != nil { 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, ''), COALESCE(lokalna_pozadina_opacity, '50'),
COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'), COALESCE(lokalna_pozadina_blur, '12'), COALESCE(lokalna_pozadina_blur_pozadine, '0'),
COALESCE(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, ''), 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`) FROM korisnici ORDER BY datum_kreiranja ASC`)
if err != nil { if err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) 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.lokalnaTema, &o.koristiLokalnuTemu, &o.datumKreiranja,
&o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur, &o.lokalnaPozadina, &o.lokalnaPozadinaOpacity, &o.lokalnaPozadinaBlur,
&o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja, &o.lokalnaPozadinaBlurPozadine, &o.lokalnaPozadinaGlassOpacity, &o.avatarPutanja,
&o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaAnimacija, &o.lokalniHover, &o.lokalnaBrzinaAnimacije,
); err != nil { ); err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err) return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
} }
@@ -229,6 +234,19 @@ func (r *sqliteKorisniciRepo) SacuvajLokalniHover(ctx context.Context, id int64,
return nil 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 { 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) _, err := r.db.ExecContext(ctx, `UPDATE korisnici SET uloga = ? WHERE id = ?`, uloga, id)
if err != nil { if err != nil {
+1
View File
@@ -215,6 +215,7 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
if korisnik != nil { if korisnik != nil {
ps.LokalnaAnimacija = korisnik.LokalnaAnimacija ps.LokalnaAnimacija = korisnik.LokalnaAnimacija
ps.LokalniHover = korisnik.LokalniHover ps.LokalniHover = korisnik.LokalniHover
ps.LokalnaBrzinaAnimacije = korisnik.LokalnaBrzinaAnimacije
} }
return ps return ps
+63 -31
View File
@@ -15,6 +15,22 @@ import (
"ntech/internal/middleware" "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 // ProfilTema prikazuje stranicu lične teme i pozadine
func (h *Handler) ProfilTema(w http.ResponseWriter, r *http.Request) { func (h *Handler) ProfilTema(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context()) k := middleware.KorisnikIzKonteksta(r.Context())
@@ -177,8 +193,7 @@ func (h *Handler) ProfilOtpremiPozadinu(w http.ResponseWriter, r *http.Request)
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uspešno otpremljena.") http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
} }
// ProfilUkloniPozadinu briše ličnu pozadinsku sliku korisnika // ProfilUkloniPozadinu briše ličnu pozadinsku sliku korisnika
@@ -202,8 +217,7 @@ func (h *Handler) ProfilUkloniPozadinu(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.") http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
} }
// ProfilSacuvajPozadinuStilove čuva opacity i blur lične pozadine // ProfilSacuvajPozadinuStilove čuva opacity i blur lične pozadine
@@ -250,8 +264,7 @@ func (h *Handler) ProfilSacuvajPozadinuStilove(w http.ResponseWriter, r *http.Re
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Podešavanja su sačuvana.") http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
} }
// SacuvajLokalnuTemu čuva korisnikovu lokalnu temu // SacuvajLokalnuTemu čuva korisnikovu lokalnu temu
@@ -284,15 +297,7 @@ func (h *Handler) SacuvajLokalnuTemu(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Tema je sačuvana.") http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil"), http.StatusSeeOther)
// 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)
} }
// SacuvajLokalnuAnimaciju čuva korisnikovu preferencu animacije tabela // 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") 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] { if !dozvoljene[animacija] {
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 { 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.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther) http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
return return
} }
if err := h.KorisniciRepo.SacuvajLokalnuBrzinuAnimacije(r.Context(), k.ID, brzina); err != nil {
middleware.SetFlash(w, r, h.DB, "uspeh", "Animacija je sačuvana.") middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju. Pokušajte ponovo.")
if ref := r.Referer(); ref != "" { http.Redirect(w, r, "/admin/profil/tema", http.StatusSeeOther)
if u, err := url.Parse(ref); err == nil {
http.Redirect(w, r, u.RequestURI(), http.StatusSeeOther)
return 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 // SacuvajLokalniHover čuva korisnikovu preferencu hover efekta na karticama
@@ -363,14 +372,39 @@ func (h *Handler) SacuvajLokalniHover(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Hover efekat je sačuvan.") http.Redirect(w, r, sacuvanoReferer(r, "/admin/profil/tema"), http.StatusSeeOther)
if ref := r.Referer(); ref != "" { }
if u, err := url.Parse(ref); err == nil {
http.Redirect(w, r, u.RequestURI(), 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 return
} }
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) 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 // ProfilOtpremiAvatar prima upload lične avatar slike korisnika
@@ -464,8 +498,7 @@ func (h *Handler) ProfilOtpremiAvatar(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je sačuvan.") http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
} }
// ProfilUkloniAvatar briše ličnu avatar sliku korisnika // ProfilUkloniAvatar briše ličnu avatar sliku korisnika
@@ -489,6 +522,5 @@ func (h *Handler) ProfilUkloniAvatar(w http.ResponseWriter, r *http.Request) {
return return
} }
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je uklonjen.") http.Redirect(w, r, "/profil/tema?sacuvano=1", http.StatusSeeOther)
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
} }
+1
View File
@@ -21,6 +21,7 @@ type Korisnik struct {
AvatarPutanja string AvatarPutanja string
LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft" LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft"
LokalniHover string // "" | "bez" | "podizanje" | "svetlost" LokalniHover string // "" | "bez" | "podizanje" | "svetlost"
LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | "0.6" | "0.8" | "1.2" (sekunde)
} }
// Sesija predstavlja aktivnu sesiju prijavljenog korisnika // Sesija predstavlja aktivnu sesiju prijavljenog korisnika
+1
View File
@@ -55,6 +55,7 @@ type PodaciStranice struct {
AppPozadinaGlassOpacity string // vrednost 0-80 (% zatamnjivanje glass elemenata) — samo za ličnu pozadinu AppPozadinaGlassOpacity string // vrednost 0-80 (% zatamnjivanje glass elemenata) — samo za ličnu pozadinu
LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft" LokalnaAnimacija string // "" | "fadeInUp" | "fadeIn" | "scaleIn" | "slideLeft"
LokalniHover string // "" | "bez" | "podizanje" | "svetlost" LokalniHover string // "" | "bez" | "podizanje" | "svetlost"
LokalnaBrzinaAnimacije string // "" | "0.2" | "0.4" | ... | "1.5" (sekunde)
} }
// PodaciDashboarda su podaci specifični za dashboard stranicu // PodaciDashboarda su podaci specifični za dashboard stranicu
+1
View File
@@ -0,0 +1 @@
ALTER TABLE korisnici ADD COLUMN lokalna_brzina_animacije TEXT NOT NULL DEFAULT '';
+25 -15
View File
@@ -5,6 +5,16 @@
padding: 0; 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 { html {
background: var(--pozadina); background: var(--pozadina);
} }
@@ -793,8 +803,8 @@ select {
animation: toastIn 0.3s ease forwards; animation: toastIn 0.3s ease forwards;
max-width: 340px; max-width: 340px;
} }
.toast-greska { background: rgba(207, 87, 87, 0.12); color: var(--greska); border: 0.5px solid var(--greska); } .toast-greska { background: #fef2f2; color: #dc2626; border: 0.5px solid #fecaca; }
.toast-uspeh { background: var(--poruka-uspeh-bg); color: var(--poruka-uspeh-boja); border: 0.5px solid var(--poruka-uspeh-boja); } .toast-uspeh { background: #f0fdf4; color: #15803d; border: 0.5px solid #86efac; }
.toast.nestaje { animation: toastOut 0.3s ease forwards; } .toast.nestaje { animation: toastOut 0.3s ease forwards; }
@keyframes toastIn { @keyframes toastIn {
from { opacity: 0; transform: translateY(12px); } from { opacity: 0; transform: translateY(12px); }
@@ -1011,7 +1021,7 @@ select {
/* animacije */ /* animacije */
@keyframes fadeInUp { @keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
} }
@@ -1020,13 +1030,13 @@ select {
to { opacity: 1; } to { opacity: 1; }
} }
@keyframes scaleIn { @keyframes blurIn {
from { opacity: 0; transform: scale(0.95); } from { opacity: 0; filter: blur(8px); }
to { opacity: 1; transform: scale(1); } to { opacity: 1; filter: blur(0); }
} }
@keyframes slideLeft { @keyframes slideLeft {
from { opacity: 0; transform: translateX(-12px); } from { opacity: 0; transform: translateX(-18px); }
to { opacity: 1; transform: translateX(0); } 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, /* 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 */ ali NE zaključava krajnji transform pa .kartica:hover lift radi i na .animiraj karticama */
.animiraj { .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 */ /* kartice ne animiramo po defaultu — animacija je samo na redovima tabela */
@@ -1066,26 +1076,26 @@ select {
[data-animacija="fadeInUp"] .animiraj, [data-animacija="fadeInUp"] .animiraj,
[data-animacija="fadeInUp"] .tabela tbody tr, [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"] .animiraj,
[data-animacija="fadeIn"] .tabela tbody tr, [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="blurIn"] .animiraj,
[data-animacija="scaleIn"] .tabela tbody tr, [data-animacija="blurIn"] .tabela tbody tr,
[data-animacija="scaleIn"] .kartica.animiraj { animation: scaleIn 0.2s ease backwards; } [data-animacija="blurIn"] .kartica.animiraj { animation: blurIn var(--anim-trajanje) ease backwards; }
[data-animacija="slideLeft"] .animiraj, [data-animacija="slideLeft"] .animiraj,
[data-animacija="slideLeft"] .tabela tbody tr, [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. /* Stepenasta (stagger) animacija redova u svim listama JEDNO mesto za sve tabele.
Animacija se primenjuje direktno na <tr> u .tabela, bez potrebe za .animiraj klasom. Animacija se primenjuje direktno na <tr> u .tabela, bez potrebe za .animiraj klasom.
Mora biti u main.css: HTMX navigacija (hx-select) odbacuje <head>, pa Mora biti u main.css: HTMX navigacija (hx-select) odbacuje <head>, pa
stilovi iz page <style> blokova ne bi važili tokom navigacije. */ stilovi iz page <style> blokova ne bi važili tokom navigacije. */
.tabela tbody tr { .tabela tbody tr {
animation: fadeInUp 0.2s ease backwards; animation: fadeInUp var(--anim-trajanje) ease backwards;
} }
.tabela tbody tr:nth-child(1) { animation-delay: 0.04s; } .tabela tbody tr:nth-child(1) { animation-delay: 0.04s; }
.tabela tbody tr:nth-child(2) { animation-delay: 0.08s; } .tabela tbody tr:nth-child(2) { animation-delay: 0.08s; }
+3
View File
@@ -22,3 +22,6 @@
--poruka-uspeh-bg: rgba(93, 184, 118, 0.15); --poruka-uspeh-bg: rgba(93, 184, 118, 0.15);
--poruka-uspeh-boja: #5db876; --poruka-uspeh-boja: #5db876;
} }
.toast-uspeh { background: #1a2e22; color: #5db876; border: 0.5px solid #2d5a3d; }
.toast-greska { background: #2e1a1a; color: #e07070; border: 0.5px solid #5a2d2d; }
+37 -10
View File
@@ -83,24 +83,23 @@
<form method="POST" action="/profil/animacija"> <form method="POST" action="/profil/animacija">
<input type="hidden" name="_csrf" value="{{.CsrfToken}}"> <input type="hidden" name="_csrf" value="{{.CsrfToken}}">
<input type="hidden" name="lokalna_brzina_animacije" id="brzina-hidden" value="{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}{{else}}0.4{{end}}">
<div class="kolona" style="gap:16px;"> <div class="kolona" style="gap:16px;">
<div style="max-width:300px;"> <div style="max-width:300px;">
<label class="polje-labela" for="anim-select">Vrsta animacije pri učitavanju</label> <label class="polje-labela" for="anim-select">Vrsta animacije pri učitavanju</label>
<select id="anim-select" name="lokalna_animacija" style="width:100%;" onchange="animPreview(this.value)"><!-- preview se primenjuje na wrapper, ne na body --> <select id="anim-select" name="lokalna_animacija" style="width:100%;" onchange="animPreview(this.value)">
<option value="" {{if eq .LokalnaAnimacija ""}}selected{{end}}>Podrazumevano (klizanje gore)</option> <option value="" {{if eq .LokalnaAnimacija ""}}selected{{end}}>Podrazumevano (klizanje gore)</option>
<option value="bez" {{if eq .LokalnaAnimacija "bez"}}selected{{end}}>Bez animacije</option> <option value="bez" {{if eq .LokalnaAnimacija "bez"}}selected{{end}}>Bez animacije</option>
<option value="fadeInUp" {{if eq .LokalnaAnimacija "fadeInUp"}}selected{{end}}>Klizanje gore (fadeInUp)</option> <option value="fadeInUp" {{if eq .LokalnaAnimacija "fadeInUp"}}selected{{end}}>Klizanje gore</option>
<option value="fadeIn" {{if eq .LokalnaAnimacija "fadeIn"}}selected{{end}}>Pojavljivanje (fadeIn)</option> <option value="fadeIn" {{if eq .LokalnaAnimacija "fadeIn"}}selected{{end}}>Pojavljivanje</option>
<option value="scaleIn" {{if eq .LokalnaAnimacija "scaleIn"}}selected{{end}}>Zumiranje (scaleIn)</option> <option value="blurIn" {{if eq .LokalnaAnimacija "blurIn"}}selected{{end}}>Zamagljivanje</option>
<option value="slideLeft" {{if eq .LokalnaAnimacija "slideLeft"}}selected{{end}}>Klizanje s leva (slideLeft)</option> <option value="slideLeft" {{if eq .LokalnaAnimacija "slideLeft"}}selected{{end}}>Klizanje s leva</option>
</select> </select>
</div> </div>
<!-- preview --> <!-- preview -->
<div> <div>
<div class="pomocni-tekst" style="margin-bottom:8px;">Pregled:</div> <div class="pomocni-tekst" style="margin-bottom:8px;">Pregled:</div>
<!-- preview wrapper nosi data-animacija atribut; isti CSS [data-animacija] .animiraj
koji radi globalno na body radi i ovde, ali izolovano — pogađa SAMO redove unutar wrappera -->
<div id="anim-preview-wrap" style="border:0.5px solid var(--ivica);border-radius:8px;overflow:hidden;max-width:340px;"> <div id="anim-preview-wrap" style="border:0.5px solid var(--ivica);border-radius:8px;overflow:hidden;max-width:340px;">
<table style="width:100%;border-collapse:collapse;"> <table style="width:100%;border-collapse:collapse;">
<tbody id="anim-preview-body"> <tbody id="anim-preview-body">
@@ -116,6 +115,20 @@
</button> </button>
</div> </div>
<!-- slider brzine unutar iste forme -->
<div style="max-width:340px;">
<label class="polje-labela" for="brzina-slider">Trajanje efekta: <span id="brzina-labela">{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}s{{else}}0.4s{{end}}</span></label>
<input type="range" id="brzina-slider"
min="0.1" max="0.8" step="0.1"
value="{{if .LokalnaBrzinaAnimacije}}{{.LokalnaBrzinaAnimacije}}{{else}}0.4{{end}}"
style="width:100%;margin-top:8px;accent-color:var(--sb-akcent);"
oninput="brzinaPromena(this.value)">
<div style="display:flex;justify-content:space-between;margin-top:4px;">
<span style="font-size:11px;color:var(--tekst-sporedni);">0.1s (brže)</span>
<span style="font-size:11px;color:var(--tekst-sporedni);">0.8s (sporije)</span>
</div>
</div>
<div> <div>
<button type="submit" class="btn-primarno">Sačuvaj</button> <button type="submit" class="btn-primarno">Sačuvaj</button>
</div> </div>
@@ -376,7 +389,7 @@ var animVrednosti = {
'bez': 'bez', 'bez': 'bez',
'fadeInUp': 'fadeInUp', 'fadeInUp': 'fadeInUp',
'fadeIn': 'fadeIn', 'fadeIn': 'fadeIn',
'scaleIn': 'scaleIn', 'blurIn': 'blurIn',
'slideLeft': 'slideLeft' 'slideLeft': 'slideLeft'
}; };
// postavlja data-animacija na PREVIEW wrapper (ne na body), pa CSS // postavlja data-animacija na PREVIEW wrapper (ne na body), pa CSS
@@ -385,10 +398,13 @@ function animPreview(val) {
var wrap = document.getElementById('anim-preview-wrap'); var wrap = document.getElementById('anim-preview-wrap');
if (!wrap) return; if (!wrap) return;
var anim = animVrednosti[val] || 'fadeInUp'; var anim = animVrednosti[val] || 'fadeInUp';
// restartuj animaciju: skini atribut, izazovi reflow, pa ga vrati // skini data-animacija i klasu animiraj sa redova da bi se animacija resetovala
wrap.removeAttribute('data-animacija'); wrap.removeAttribute('data-animacija');
void wrap.offsetHeight; // reflow da bi se animacija ponovo okinula var redovi = wrap.querySelectorAll('.animiraj');
redovi.forEach(function(r) { r.classList.remove('animiraj'); });
void wrap.offsetHeight; // reflow — bez ovoga browser spoji promene i ne restartuje
wrap.setAttribute('data-animacija', anim); wrap.setAttribute('data-animacija', anim);
redovi.forEach(function(r) { r.classList.add('animiraj'); });
} }
// "Ponovi pregled" — ponovo okida animaciju za trenutno izabrani stil // "Ponovi pregled" — ponovo okida animaciju za trenutno izabrani stil
function animPreviewPonovi() { function animPreviewPonovi() {
@@ -399,5 +415,16 @@ function animPreviewPonovi() {
var sel = document.getElementById('anim-select'); var sel = document.getElementById('anim-select');
if (sel) animPreview(sel.value); if (sel) animPreview(sel.value);
})(); })();
function brzinaPromena(val) {
var v = parseFloat(val).toFixed(1);
document.getElementById('brzina-hidden').value = v;
document.getElementById('brzina-labela').textContent = v + 's';
// primeni odmah — korisnik vidi efekat pre čuvanja
document.body.dataset.brzinaAnimacije = v;
// ažuriraj i preview wrapper da odmah odrazi novu brzinu
var wrap = document.getElementById('anim-preview-wrap');
if (wrap) wrap.style.setProperty('--anim-trajanje', v + 's');
}
</script> </script>
{{end}} {{end}}
@@ -57,12 +57,16 @@
</div> </div>
</div> </div>
<div style="display:flex;gap:8px;flex-wrap:wrap;"> <div style="display:flex;gap:8px;flex-wrap:wrap;">
{{if not (or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto"))}}
<a href="/servis/{{.Nalog.ID}}/stampa" target="_blank" class="btn-sekundarno"> <a href="/servis/{{.Nalog.ID}}/stampa" target="_blank" class="btn-sekundarno">
Radni nalog Radni nalog
</a> </a>
{{end}}
{{if or (eq .Nalog.Status "Završeno") (eq .Nalog.Status "Preuzeto")}}
<a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno"> <a href="/servis/{{.Nalog.ID}}/otpremnica" target="_blank" class="btn-sekundarno">
Otpremnica Otpremnica
</a> </a>
{{end}}
<a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno"> <a href="/servis/izmeni/{{.Nalog.ID}}" class="btn-primarno">
Izmeni nalog Izmeni nalog
</a> </a>
+55 -3
View File
@@ -110,7 +110,7 @@
</script> </script>
{{end}} {{end}}
</head> </head>
<body{{if .LokalnaAnimacija}} data-animacija="{{.LokalnaAnimacija}}"{{end}}{{if .LokalniHover}} data-hover="{{.LokalniHover}}"{{end}}> <body{{if .LokalnaAnimacija}} data-animacija="{{.LokalnaAnimacija}}"{{end}}{{if .LokalniHover}} data-hover="{{.LokalniHover}}"{{end}}{{if .LokalnaBrzinaAnimacije}} data-brzina-animacije="{{.LokalnaBrzinaAnimacije}}"{{end}}>
<div class="raspored"> <div class="raspored">
<div class="sidebar-overlay" id="sidebar-overlay"></div> <div class="sidebar-overlay" id="sidebar-overlay"></div>
{{template "sidebar" .}} {{template "sidebar" .}}
@@ -307,6 +307,8 @@
function ntechInicijalizuj() { function ntechInicijalizuj() {
ntechKonvertujPoruke(); ntechKonvertujPoruke();
var m = document.querySelector('meta[name="csrf-token"]'); var m = document.querySelector('meta[name="csrf-token"]');
if (m && m.content) { if (m && m.content) {
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) { document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
@@ -323,14 +325,64 @@
e.preventDefault(); e.preventDefault();
window._ntechPotvrdi(el.getAttribute('data-potvrda')).then(function(ok) { window._ntechPotvrdi(el.getAttribute('data-potvrda')).then(function(ok) {
if (!ok) return; if (!ok) return;
// dugme unutar forme — submit forme
var forma = el.closest('form'); var forma = el.closest('form');
if (forma) { forma.submit(); return; } if (forma) { forma.submit(); return; }
// link
if (el.href) { window.location.href = el.href; } if (el.href) { window.location.href = el.href; }
}); });
}); });
}); });
// AJAX submit — forme koje serveru vraćaju ?sacuvano= ostaju na stranici (scroll se ne gubi)
document.querySelectorAll('form[method="POST"],form[method="post"]').forEach(function(f) {
if (f._ajaxSave) return;
f._ajaxSave = true;
f.addEventListener('submit', function(e) {
// file upload i forme sa data-full-reload šalju se normalno
if (f.enctype === 'multipart/form-data' || f.dataset.fullReload) return;
e.preventDefault();
// prikazujemo dugme kao zauzeto
var btn = f.querySelector('[type="submit"]');
if (btn) btn.disabled = true;
// URLSearchParams šalje kao application/x-www-form-urlencoded
// što Go-ov r.FormValue() (i CSRF middleware) može da pročita
fetch(f.action || location.href, {
method: 'POST',
body: new URLSearchParams(new FormData(f)),
redirect: 'follow'
}).then(function(res) {
var finUrl = new URL(res.url);
if (finUrl.search.indexOf('sacuvano') !== -1) {
// uspeh — prikaži toast, ostani na stranici
window.ntechToast('Sačuvano', 'uspeh');
if (btn) btn.disabled = false;
// odmah primeni podešavanja koja menjaju globalne atribute body-ja
var anim = f.querySelector('[name="lokalna_animacija"]');
if (anim) {
if (anim.value) document.body.dataset.animacija = anim.value;
else delete document.body.dataset.animacija;
}
var hov = f.querySelector('[name="lokalni_hover"]');
if (hov) {
if (hov.value) document.body.dataset.hover = hov.value;
else delete document.body.dataset.hover;
}
var brzina = f.querySelector('[name="lokalna_brzina_animacije"]');
if (brzina) {
if (brzina.value) document.body.dataset.brzinaAnimacije = brzina.value;
else delete document.body.dataset.brzinaAnimacije;
}
// promena teme zahteva reload (menja se ceo CSS fajl)
if (f.querySelector('[name="lokalna_tema"]')) location.reload();
} else {
// greška ili redirect na drugu stranicu — navigiraj normalno
location.href = res.url;
}
}).catch(function() {
// mrežna greška — pošalji formu normalno
f.submit();
});
});
});
} }
if (!window._ntechCsrfDodato) { if (!window._ntechCsrfDodato) {
window._ntechCsrfDodato = true; window._ntechCsrfDodato = true;