Podešavanja: pozadinska slika i glass efekt za aplikaciju sa live preview sliderom

This commit is contained in:
2026-06-05 23:49:05 +02:00
parent 9af712edd3
commit 8def13e855
14 changed files with 685 additions and 15 deletions
+11
View File
@@ -90,5 +90,16 @@ func (h *Handler) popuniPodaciStranice(r *http.Request, podesavanja map[string]s
}
ps.CsrfToken = middleware.CsrfToken(r.Context())
ps.Flash = middleware.GetFlash(r, h.DB)
ps.AppPozadina = podesavanja["app_pozadina"]
ps.AppPozadinaOpacity = podesavanja["app_pozadina_opacity"]
if ps.AppPozadinaOpacity == "" {
ps.AppPozadinaOpacity = "50"
}
ps.AppPozadinaBlur = podesavanja["app_pozadina_blur"]
if ps.AppPozadinaBlur == "" {
ps.AppPozadinaBlur = "12"
}
return ps
}
+349 -13
View File
@@ -1,6 +1,8 @@
package handler
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"log"
@@ -9,6 +11,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -22,19 +25,23 @@ import (
// PodaciPodesavanja su podaci za stranicu podešavanja
type PodaciPodesavanja struct {
model.PodaciStranice
NazivFirme string
Podnazlov string
Adresa string
Telefon string
PIB string
LogoTip string
LogoPutanja string
Tema string
Sacuvano bool
Verzija string
LogoGreska string
BackupVracen bool
Backupi []BackupInfo
NazivFirme string
Podnazlov string
Adresa string
Telefon string
PIB string
LogoTip string
LogoPutanja string
Tema string
Sacuvano bool
Verzija string
LogoGreska string
BackupVracen bool
Backupi []BackupInfo
LoginPozadina string
AppPozadina string
AppPozadinaOpacity string
AppPozadinaBlur string
}
// BackupInfo opisuje jedan backup fajl
@@ -78,6 +85,18 @@ func (h *Handler) Podesavanja(w http.ResponseWriter, r *http.Request) {
Verzija: h.Verzija,
LogoGreska: r.URL.Query().Get("logo_greska"),
Backupi: ucitajListuBackupa(),
LoginPozadina: podesavanja["login_pozadina"],
AppPozadina: podesavanja["app_pozadina"],
AppPozadinaOpacity: func() string {
v := podesavanja["app_pozadina_opacity"]
if v == "" { return "50" }
return v
}(),
AppPozadinaBlur: func() string {
v := podesavanja["app_pozadina_blur"]
if v == "" { return "12" }
return v
}(),
}
h.renderujTemplate(w, "podesavanja", podaci)
@@ -341,6 +360,323 @@ func (h *Handler) OtpremiLogo(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/podesavanja?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)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf) + ext, nil
}
// OtpremiLoginPozadinu prima multipart upload slike i čuva je kao pozadinsku sliku login stranice
func (h *Handler) OtpremiLoginPozadinu(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.login_pozadina") {
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 5<<20+4096)
if err := r.ParseMultipartForm(5 << 20); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
fajl, zaglavlje, err := r.FormFile("login_pozadina")
if err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Nije odabran fajl.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
defer fajl.Close()
if zaglavlje.Size > 5<<20 {
middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
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, "/podesavanja", http.StatusSeeOther)
return
}
// proveravamo stvarni tip sadržaja (magic bytes)
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, "/podesavanja", 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, "/podesavanja", http.StatusSeeOther)
return
}
// briše staru pozadinu sa diska ako postoji
staraPodesavanja, _ := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if stara := staraPodesavanja["login_pozadina"]; stara != "" {
// putanja u bazi je oblika /static/uploads/ime.ext?v=..., izvlačimo samo ime fajla
deoBezverzije := strings.Split(stara, "?")[0]
staroIme := filepath.Base(deoBezverzije)
os.Remove(filepath.Join("web/static/uploads", staroIme))
}
novoIme, err := generisiImeUploada(ext)
if err != nil {
log.Printf("upload login pozadine: greška pri generisanju imena: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
odrediste := filepath.Join("web/static/uploads", novoIme)
dst, err := os.Create(odrediste)
if err != nil {
log.Printf("upload login pozadine: ne mogu kreirati fajl: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
defer dst.Close()
if _, err := io.Copy(dst, fajl); err != nil {
log.Printf("upload login pozadine: greška pri kopiranju: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
putanja := fmt.Sprintf("/static/uploads/%s?v=%d", novoIme, time.Now().Unix())
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "login_pozadina", putanja); err != nil {
log.Printf("upload login pozadine: greška pri čuvanju putanje: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uspešno otpremljena.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
}
// UkloniLoginPozadinu briše pozadinsku sliku login stranice sa diska i iz podešavanja
func (h *Handler) UkloniLoginPozadinu(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.login_pozadina") {
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
return
}
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err == nil {
if stara := podesavanja["login_pozadina"]; stara != "" {
deoBezverzije := strings.Split(stara, "?")[0]
staroIme := filepath.Base(deoBezverzije)
os.Remove(filepath.Join("web/static/uploads", staroIme))
}
}
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "login_pozadina", ""); err != nil {
log.Printf("ukloni login pozadinu: greška pri čuvanju: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju slike.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika je uklonjena.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
}
// OtpremiAppPozadinu prima multipart upload slike i čuva je kao pozadinsku sliku aplikacije
func (h *Handler) OtpremiAppPozadinu(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.app_pozadina") {
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 5<<20+4096)
if err := r.ParseMultipartForm(5 << 20); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
fajl, zaglavlje, err := r.FormFile("app_pozadina")
if err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Nije odabran fajl.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
defer fajl.Close()
if zaglavlje.Size > 5<<20 {
middleware.SetFlash(w, r, h.DB, "greska", "Fajl je prevelik (maksimum 5 MB).")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
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, "/podesavanja", 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, "/podesavanja", 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, "/podesavanja", http.StatusSeeOther)
return
}
// briše staru app pozadinu sa diska ako postoji
staraPodesavanja, _ := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if stara := staraPodesavanja["app_pozadina"]; stara != "" {
deoBezverzije := strings.Split(stara, "?")[0]
staroIme := filepath.Base(deoBezverzije)
os.Remove(filepath.Join("web/static/uploads", staroIme))
}
novoIme, err := generisiImeUploada(ext)
if err != nil {
log.Printf("upload app pozadine: greška pri generisanju imena: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
odrediste := filepath.Join("web/static/uploads", novoIme)
dst, err := os.Create(odrediste)
if err != nil {
log.Printf("upload app pozadine: ne mogu kreirati fajl: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
defer dst.Close()
if _, err := io.Copy(dst, fajl); err != nil {
log.Printf("upload app pozadine: greška pri kopiranju: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju fajla.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
putanja := fmt.Sprintf("/static/uploads/%s?v=%d", novoIme, time.Now().Unix())
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "app_pozadina", putanja); err != nil {
log.Printf("upload app pozadine: greška pri čuvanju putanje: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika aplikacije je uspešno otpremljena.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
}
// UkloniAppPozadinu briše pozadinsku sliku aplikacije sa diska i iz podešavanja
func (h *Handler) UkloniAppPozadinu(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.app_pozadina") {
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
return
}
podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(r.Context(), h.DB)
if err == nil {
if stara := podesavanja["app_pozadina"]; stara != "" {
deoBezverzije := strings.Split(stara, "?")[0]
staroIme := filepath.Base(deoBezverzije)
os.Remove(filepath.Join("web/static/uploads", staroIme))
}
}
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "app_pozadina", ""); err != nil {
log.Printf("ukloni app pozadinu: greška pri čuvanju: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri uklanjanju slike.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Pozadinska slika aplikacije je uklonjena.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
}
// SacuvajAppPozadinaStilove čuva vrednosti zamućenja i prozirnosti pozadine aplikacije
func (h *Handler) SacuvajAppPozadinaStilove(w http.ResponseWriter, r *http.Request) {
k := middleware.KorisnikIzKonteksta(r.Context())
if k == nil || !h.DozvoleRepo.ImaDozvolu(r.Context(), k.Uloga, "podesavanja.app_pozadina") {
http.Error(w, "Nemate dozvolu za ovu akciju.", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čitanju forme.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
blurStr := r.FormValue("blur")
opacityStr := r.FormValue("opacity")
blurVal, err := strconv.Atoi(blurStr)
if err != nil || blurVal < 0 || blurVal > 20 {
middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost zamućenja.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
opacityVal, err := strconv.Atoi(opacityStr)
if err != nil || opacityVal < 0 || opacityVal > 80 {
middleware.SetFlash(w, r, h.DB, "greska", "Neispravna vrednost prozirnosti.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "app_pozadina_blur", blurStr); err != nil {
log.Printf("stilovi app pozadine: greška pri čuvanju blur: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
if err := ntechsqlite.SacuvajPodesavanje(r.Context(), h.DB, "app_pozadina_opacity", opacityStr); err != nil {
log.Printf("stilovi app pozadine: greška pri čuvanju opacity: %v", err)
middleware.SetFlash(w, r, h.DB, "greska", "Greška pri čuvanju podešavanja.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
return
}
middleware.SetFlash(w, r, h.DB, "uspeh", "Izgled pozadine aplikacije je sačuvan.")
http.Redirect(w, r, "/podesavanja", http.StatusSeeOther)
}
// PromeniTemu menja aktivnu temu i vraća na prethodnu stranicu
func (h *Handler) PromeniTemu(w http.ResponseWriter, r *http.Request) {
tema := chi.URLParam(r, "tema")
+11 -2
View File
@@ -9,6 +9,7 @@ import (
"time"
"ntech/internal/auth"
ntechsqlite "ntech/internal/db/sqlite"
"ntech/internal/middleware"
)
@@ -29,9 +30,17 @@ func (h *Handler) PrikazPrijave(w http.ResponseWriter, r *http.Request) {
}
greska := r.URL.Query().Get("greska")
// login_pozadina se čita bez prijavljenog korisnika — koristimo background kontekst
loginPozadina := ""
if podesavanja, err := ntechsqlite.DohvatiSvaPodesavanja(context.Background(), h.DB); err == nil {
loginPozadina = podesavanja["login_pozadina"]
}
h.renderujStandalone(w, "prijava", map[string]any{
"Greska": greska,
"CsrfToken": middleware.CsrfToken(r.Context()),
"Greska": greska,
"CsrfToken": middleware.CsrfToken(r.Context()),
"LoginPozadina": loginPozadina,
})
}
+5
View File
@@ -43,6 +43,8 @@ var sveAkcije = []string{
"korisnik.uloga",
"backup.pregled",
"backup.pokreni",
"podesavanja.login_pozadina",
"podesavanja.app_pozadina",
}
// SveAkcije vraća listu svih poznatih akcija — koristi se pri inicijalizaciji baze i resetu
@@ -100,6 +102,9 @@ func ImaDozvolu(uloga, akcija string) bool {
// backup
case "backup.pregled", "backup.pokreni":
return true
// pozadinske slike
case "podesavanja.login_pozadina", "podesavanja.app_pozadina":
return true
}
return false
+26
View File
@@ -88,3 +88,29 @@ func (n ServisniNalog) AvansStr() string {
}
return fmt.Sprintf("%.2f", *n.Avans)
}
// PreostaloZaNaplatu vraća razliku konacna_cena avans, minimum 0.
// Vraća nil ako konačna cena nije uneta.
func (n ServisniNalog) PreostaloZaNaplatu() *float64 {
if n.CenaKonacna == nil {
return nil
}
avans := 0.0
if n.Avans != nil {
avans = *n.Avans
}
v := *n.CenaKonacna - avans
if v < 0 {
v = 0
}
return &v
}
// PreostaloZaNaplatuStr vraća formatirano preostalo za naplatu, ili prazan string
func (n ServisniNalog) PreostaloZaNaplatuStr() string {
v := n.PreostaloZaNaplatu()
if v == nil {
return ""
}
return fmt.Sprintf("%.2f", *v)
}
+4
View File
@@ -44,6 +44,10 @@ type PodaciStranice struct {
CsrfToken string // CSRF zaštitni token za forme
Dozvole map[string]bool // mapa akcija → dozvoljeno/nije
Flash *FlashPoruka // jednokratna poruka nakon redirecta
// app pozadina — popunjava se iz podešavanja za sve stranice
AppPozadina string
AppPozadinaOpacity string // vrednost 0-80 (% overlay zatamnjivanja)
AppPozadinaBlur string // vrednost 0-20 (px backdrop-filter blur)
}
// PodaciDashboarda su podaci specifični za dashboard stranicu