Topbar: logo firme + naslov; avatar upload; uklanjanje logo zone

- Topbar: logo slika firme (toggle on/off) pa naslov stranice; bez teksta firme
- Sidebar: samo naziv firme i podnaslov (tekst), bez slike loga
- Avatar: korisnik uploaduje ličnu sliku u Profil > Tema > Avatar;
  prikazuje se kao dugme za meni (desno u topbaru); fallback inicijali
- Logo firme kartica: dugme "Ukloni sliku" + ruta /podesavanja/logo/ukloni
- Logo zona iz podešavanja uklonjena; jedan iOS toggle za prikaz loga u topbaru
- Migracije 049 (topbar_logo_slika/tekst) i 050 (avatar_putanja na korisnicima)
- iOS-style .toggl switch u main.css
This commit is contained in:
2026-06-16 02:46:48 +02:00
parent 3c5c8060c1
commit 85cb1e25c7
15 changed files with 320 additions and 60 deletions
+1
View File
@@ -149,6 +149,7 @@ type KorisniciRepository interface {
SacuvajTotpTajnu(ctx context.Context, id int64, tajna string) error
SacuvajLokalnuTemu(ctx context.Context, id int64, lokalnaTema string, koristi bool) error
SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine, glassOpacity string) error
SacuvajAvatar(ctx context.Context, id int64, putanja string) error
PostojiIjedan(ctx context.Context) (bool, error)
Obrisi(ctx context.Context, id int64) error
}
+20 -8
View File
@@ -21,7 +21,7 @@ type sqliteKorisniciRepo struct {
// NULL vrednosti — deljeno između skeniraiKorisnika (jedan red) i Lista (više redova)
func dodeliOpcijeKorisnika(k *model.Korisnik, aktivan, koristiLokalnuTemu int,
lokalnaTema, lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur,
lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity sql.NullString,
lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity, avatarPutanja sql.NullString,
datumKreiranja time.Time) {
k.Aktivan = aktivan == 1
k.LokalnaTema = lokalnaTema.String
@@ -32,6 +32,7 @@ func dodeliOpcijeKorisnika(k *model.Korisnik, aktivan, koristiLokalnuTemu int,
k.LokalnaPozadinaBlur = lokalnaPozadinaBlur.String
k.LokalnaPozadinaBlurPozadine = lokalnaPozadinaBlurPozadine.String
k.LokalnaPozadinaGlassOpacity = lokalnaPozadinaGlassOpacity.String
k.AvatarPutanja = avatarPutanja.String
}
// skeniraiKorisnika čita jedan red iz baze i popunjava model.Korisnik
@@ -40,18 +41,19 @@ func skeniraiKorisnika(row interface{ Scan(...any) error }) (*model.Korisnik, er
var aktivan, koristiLokalnuTemu int
var lokalnaTema sql.NullString
var lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity sql.NullString
var avatarPutanja sql.NullString
var datumKreiranja time.Time
if err := row.Scan(
&k.ID, &k.KorisnickoIme, &k.LozinkaHash, &k.Uloga, &aktivan, &k.TotpTajna,
&lokalnaTema, &koristiLokalnuTemu, &datumKreiranja,
&lokalnaPozadina, &lokalnaPozadinaOpacity, &lokalnaPozadinaBlur,
&lokalnaPozadinaBlurPozadine, &lokalnaPozadinaGlassOpacity,
&lokalnaPozadinaBlurPozadine, &lokalnaPozadinaGlassOpacity, &avatarPutanja,
); err != nil {
return nil, err
}
dodeliOpcijeKorisnika(k, aktivan, koristiLokalnuTemu, lokalnaTema,
lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur,
lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity, datumKreiranja)
lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity, avatarPutanja, datumKreiranja)
return k, nil
}
@@ -91,7 +93,7 @@ func (r *sqliteKorisniciRepo) DohvatiPoImenu(ctx context.Context, korisnickoIme
COALESCE(lokalna_tema, ''), koristi_lokalnu_temu, datum_kreiranja,
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(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, '')
FROM korisnici WHERE korisnicko_ime = ?`, korisnickoIme)
k, err := skeniraiKorisnika(row)
if err != nil {
@@ -107,7 +109,7 @@ func (r *sqliteKorisniciRepo) DohvatiPoID(ctx context.Context, id int64) (*model
COALESCE(lokalna_tema, ''), koristi_lokalnu_temu, datum_kreiranja,
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(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, '')
FROM korisnici WHERE id = ?`, id)
k, err := skeniraiKorisnika(row)
if err != nil {
@@ -123,7 +125,7 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
COALESCE(lokalna_tema, ''), koristi_lokalnu_temu, datum_kreiranja,
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(lokalna_pozadina_glass_opacity, '10'), COALESCE(avatar_putanja, '')
FROM korisnici ORDER BY datum_kreiranja ASC`)
if err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
@@ -135,22 +137,32 @@ func (r *sqliteKorisniciRepo) Lista(ctx context.Context) ([]model.Korisnik, erro
var aktivan, koristiLokalnuTemu int
var lokalnaTema sql.NullString
var lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur, lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity sql.NullString
var avatarPutanja sql.NullString
var datumKreiranja time.Time
if err := rows.Scan(&k.ID, &k.KorisnickoIme, &k.Uloga, &aktivan, &k.TotpTajna,
&lokalnaTema, &koristiLokalnuTemu, &datumKreiranja,
&lokalnaPozadina, &lokalnaPozadinaOpacity, &lokalnaPozadinaBlur, &lokalnaPozadinaBlurPozadine,
&lokalnaPozadinaGlassOpacity); err != nil {
&lokalnaPozadinaGlassOpacity, &avatarPutanja); err != nil {
return nil, fmt.Errorf("ntech: korisnici.Lista: %w", err)
}
dodeliOpcijeKorisnika(&k, aktivan, koristiLokalnuTemu, lokalnaTema,
lokalnaPozadina, lokalnaPozadinaOpacity, lokalnaPozadinaBlur,
lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity, datumKreiranja)
lokalnaPozadinaBlurPozadine, lokalnaPozadinaGlassOpacity, avatarPutanja, datumKreiranja)
r.desifrujTotpTajnu(&k)
lista = append(lista, k)
}
return lista, nil
}
func (r *sqliteKorisniciRepo) SacuvajAvatar(ctx context.Context, id int64, putanja string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE korisnici SET avatar_putanja = ? WHERE id = ?`, putanja, id)
if err != nil {
return fmt.Errorf("ntech: korisnici.SacuvajAvatar: %w", err)
}
return nil
}
func (r *sqliteKorisniciRepo) SacuvajLokalnuPozadinu(ctx context.Context, id int64, pozadina, opacity, blur, blurPozadine, glassOpacity string) error {
_, err := r.db.ExecContext(ctx,
`UPDATE korisnici SET lokalna_pozadina = ?, lokalna_pozadina_opacity = ?, lokalna_pozadina_blur = ?, lokalna_pozadina_blur_pozadine = ?, lokalna_pozadina_glass_opacity = ? WHERE id = ?`,
+8 -6
View File
@@ -161,12 +161,13 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
tema := "tamna"
ps := model.PodaciStranice{
Tema: tema,
NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"],
LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"],
Korisnik: "Admin",
Tema: tema,
NazivFirme: podesavanja["naziv_firme"],
Podnazlov: podesavanja["podnazlov"],
LogoPutanja: podesavanja["logo_putanja"],
TopbarLogoSlika: podesavanja["topbar_logo_slika"] == "1",
TopbarLogoTekst: podesavanja["topbar_logo_tekst"] == "1",
Korisnik: "Admin",
}
var korisnik *model.Korisnik
if k := middleware.KorisnikIzKonteksta(r.Context()); k != nil {
@@ -174,6 +175,7 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
ps.Korisnik = k.KorisnickoIme
ps.KorisnikIme = k.KorisnickoIme
ps.KorisnikUloga = k.Uloga
ps.AvatarPutanja = k.AvatarPutanja
ps.Dozvole = h.DozvoleRepo.SveDozvole(r.Context(), k.Uloga)
// lokalna tema korisnika — primenjuje se uvek kada je postavljena, bez obzira na KoristiLokalnuTemu
if k.LokalnaTema != "" {
+39 -10
View File
@@ -28,8 +28,9 @@ type PodaciPodesavanja struct {
Adresa string
Telefon string
PIB string
LogoTip string
LogoPutanja string
LogoPutanja string
TopbarLogoSlika bool
TopbarLogoTekst bool
// profil firme — pravni/poreski status (Faza 0); određuje koji se zakonski moduli pale
FirmaPravniOblik string
FirmaPdvObveznik string
@@ -81,8 +82,9 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) {
Adresa: podesavanja["adresa"],
Telefon: podesavanja["telefon"],
PIB: podesavanja["pib"],
LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"],
TopbarLogoSlika: podesavanja["topbar_logo_slika"] == "1",
TopbarLogoTekst: podesavanja["topbar_logo_tekst"] == "1",
Sacuvano: r.URL.Query().Get("sacuvano") == "1",
BackupVracen: r.URL.Query().Get("sacuvano") == "vraceno",
Verzija: h.Verzija,
@@ -233,13 +235,24 @@ func (h *Handler) SacuvajPodesavanja(w http.ResponseWriter, r *http.Request) {
return
}
// checkbox-i: šalju vrednost samo kada su čekirani, pa ih uvek eksplicitno čitamo
topbarLogoSlika := "0"
if r.FormValue("topbar_logo_slika") == "1" {
topbarLogoSlika = "1"
}
topbarLogoTekst := "0"
if r.FormValue("topbar_logo_tekst") == "1" {
topbarLogoTekst = "1"
}
polja := map[string]string{
"naziv_firme": r.FormValue("naziv_firme"),
"podnazlov": r.FormValue("podnazlov"),
"adresa": r.FormValue("adresa"),
"telefon": r.FormValue("telefon"),
"pib": r.FormValue("pib"),
"logo_tip": r.FormValue("logo_tip"),
"naziv_firme": r.FormValue("naziv_firme"),
"podnazlov": r.FormValue("podnazlov"),
"adresa": r.FormValue("adresa"),
"telefon": r.FormValue("telefon"),
"pib": r.FormValue("pib"),
"topbar_logo_slika": topbarLogoSlika,
"topbar_logo_tekst": topbarLogoTekst,
// profil firme (Faza 0) — radio dugmad uvek šalju vrednost, pa se uredno čuvaju
"firma_pravni_oblik": r.FormValue("firma_pravni_oblik"),
"firma_pdv_obveznik": r.FormValue("firma_pdv_obveznik"),
@@ -412,6 +425,21 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/podesavanja?sacuvano=1", http.StatusSeeOther)
}
// UkloniLogo briše logo fajl i čisti putanju iz podešavanja
func (h *Handler) UkloniLogo(w http.ResponseWriter, r *http.Request) {
if _, ok := h.zahtevajDozvolu(w, r, "podesavanja.izmeni"); !ok {
return
}
stari, _ := filepath.Glob("web/static/uploads/logo.*")
for _, s := range stari {
os.Remove(s)
}
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "logo_putanja", ""); err != nil {
slog.Error("ukloni logo: greška pri čuvanju", "error", err)
}
http.Redirect(w, r, "/admin/podesavanja/opste?sacuvano=1", http.StatusSeeOther)
}
// generisiImeUploada vraća slučajno hex ime (16 bajtova) sa datom ekstenzijom
func generisiImeUploada(ext string) (string, error) {
buf := make([]byte, 16)
@@ -624,8 +652,9 @@ func (h *Handler) napuniPodaciPodesavanja(r *http.Request, naslov string) (Podac
Adresa: podesavanja["adresa"],
Telefon: podesavanja["telefon"],
PIB: podesavanja["pib"],
LogoTip: podesavanja["logo_tip"],
LogoPutanja: podesavanja["logo_putanja"],
TopbarLogoSlika: podesavanja["topbar_logo_slika"] == "1",
TopbarLogoTekst: podesavanja["topbar_logo_tekst"] == "1",
FirmaPravniOblik: vrednostIliDefault(podesavanja, "firma_pravni_oblik", "pausalac"),
FirmaPdvObveznik: vrednostIliDefault(podesavanja, "firma_pdv_obveznik", "ne"),
FirmaFiskalizacija: vrednostIliDefault(podesavanja, "firma_fiskalizacija", "ne"),
+115
View File
@@ -289,3 +289,118 @@ func (h *Handler) SacuvajLokalnuTemu(w http.ResponseWriter, r *http.Request) {
}
http.Redirect(w, r, "/admin/profil", http.StatusSeeOther)
}
// ProfilOtpremiAvatar prima upload lične avatar slike korisnika
func (h *Handler) ProfilOtpremiAvatar(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 2<<20+4096)
if err := r.ParseMultipartForm(2 << 20); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 2 MB).")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
return
}
fajl, zaglavlje, err := r.FormFile("avatar")
if err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Nije odabran fajl.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
return
}
defer fajl.Close()
ext := strings.ToLower(filepath.Ext(zaglavlje.Filename))
dozvoljenoExt := map[string]string{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
}
ocekivaniMime, ok := dozvoljenoExt[ext]
if !ok {
middleware.SetFlash(w, r, h.DB, "greska", "Dozvoljeni formati su JPG, PNG i WebP.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
return
}
buf := make([]byte, 512)
n, _ := fajl.Read(buf)
stvarniMime := http.DetectContentType(buf[:n])
if !strings.HasPrefix(stvarniMime, ocekivaniMime) {
middleware.SetFlash(w, r, h.DB, "greska", "Sadržaj fajla ne odgovara odabranoj ekstenziji.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
return
}
if _, err := fajl.Seek(0, io.SeekStart); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri obradi fajla.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
return
}
// briše stari avatar sa diska
svezi, _ := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID)
if svezi != nil && svezi.AvatarPutanja != "" {
deo, _, _ := strings.Cut(svezi.AvatarPutanja, "?")
os.Remove(filepath.Join("web/static/uploads", filepath.Base(deo)))
}
novoIme := fmt.Sprintf("korisnik_%d_avatar%s", k.ID, ext)
odrediste := filepath.Join("web/static/uploads", novoIme)
dst, err := os.Create(odrediste)
if err != nil {
slog.Error("ProfilOtpremiAvatar: ne mogu kreirati fajl", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
return
}
defer dst.Close()
if _, err := io.Copy(dst, fajl); err != nil {
slog.Error("ProfilOtpremiAvatar: greška pri kopiranju", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
return
}
// cache-buster verzija u URL-u
v := fmt.Sprintf("%d", time.Now().Unix())
putanja := fmt.Sprintf("/static/uploads/%s?v=%s", novoIme, v)
if err := h.KorisniciRepo.SacuvajAvatar(r.Context(), k.ID, putanja); err != nil {
slog.Error("ProfilOtpremiAvatar: greška pri čuvanju u bazi", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je sačuvan.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
}
// ProfilUkloniAvatar briše ličnu avatar sliku korisnika
func (h *Handler) ProfilUkloniAvatar(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil {
http.Redirect(w, r, "/prijava", http.StatusSeeOther)
return
}
svezi, _ := h.KorisniciRepo.DohvatiPoID(r.Context(), k.ID)
if svezi != nil && svezi.AvatarPutanja != "" {
deo, _, _ := strings.Cut(svezi.AvatarPutanja, "?")
os.Remove(filepath.Join("web/static/uploads", filepath.Base(deo)))
}
if err := h.KorisniciRepo.SacuvajAvatar(r.Context(), k.ID, ""); err != nil {
slog.Error("ProfilUkloniAvatar", "error", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju avatara.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Avatar je uklonjen.")
http.Redirect(w, r, "/profil/tema", http.StatusSeeOther)
}
+1
View File
@@ -18,6 +18,7 @@ type Korisnik struct {
LokalnaPozadinaBlur string
LokalnaPozadinaBlurPozadine string
LokalnaPozadinaGlassOpacity string
AvatarPutanja string
}
// Sesija predstavlja aktivnu sesiju prijavljenog korisnika
+5 -3
View File
@@ -36,9 +36,11 @@ type PodaciStranice struct {
Tema string
NazivFirme string
Podnazlov string
LogoTip string // "sa_nazivom", "bez_naziva", "slika"
LogoPutanja string // putanja do slike, koristi se samo kada je LogoTip "slika"
Korisnik string
LogoPutanja string // putanja do slike loga firme
TopbarLogoSlika bool // prikaži logo sliku u topbaru
TopbarLogoTekst bool // prikaži naziv firme u topbaru
AvatarPutanja string // putanja do lične avatar slike korisnika
Korisnik string
KorisnikIme string // korisničko ime prijavljenog korisnika
KorisnikUloga string // uloga: "superadmin", "admin", "radnik"
CsrfToken string // CSRF zaštitni token za forme