commit 17b81a808af73e62794f218c0013e0dd1e336fa5 Author: Welton Moura Date: Wed Oct 21 18:06:25 2026 -0300 feat: initpage com sync Go + auth + frontend em web/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..011aa42 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +PORT=8080 +BASE_PATH=/initpage +APP_ORIGIN=https://apps.weltonmoura.com.br +SESSION_SECRET=change-me-to-a-long-random-string +TURNSTILE_SECRET= +TURNSTILE_SITEKEY= +DB_PATH=./data/initpage.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67013b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/server +/data/*.db +/data/*.db-* +*.log +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4fb583d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.22-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server + +FROM alpine:latest +WORKDIR /app +RUN adduser -D -u 1000 appuser +COPY --from=builder /app/server . +COPY --from=builder /app/web ./web +RUN mkdir -p /app/data && chown -R appuser:appuser /app +USER appuser +EXPOSE 8080 +ENV PORT=8080 +CMD ["./server"] diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..606a8f8 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/alexedwards/scs/v2" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/joho/godotenv" + "golang.org/x/time/rate" + + "github.com/weltonmoura/initpage/internal/config" + "github.com/weltonmoura/initpage/internal/db" + "github.com/weltonmoura/initpage/internal/handlers" + mw "github.com/weltonmoura/initpage/internal/middleware" + "github.com/weltonmoura/initpage/internal/sync" +) + +func main() { + _ = godotenv.Load() + cfg := config.Load() + + dbConn, err := db.Init(cfg.DBPath) + if err != nil { + log.Fatal(err) + } + defer dbConn.Close() + + sessionManager := scs.New() + sessionManager.Lifetime = 24 * time.Hour + sessionManager.Cookie.HttpOnly = true + sessionManager.Cookie.SameSite = http.SameSiteLaxMode + sessionManager.Cookie.Secure = strings.HasPrefix(cfg.AppOrigin, "https://") + + authH := &handlers.Auth{DB: dbConn, SM: sessionManager, Cfg: cfg} + syncH := &sync.Sync{DB: dbConn} + rl := mw.NewIPRateLimiter(rate.Every(time.Minute/10), 10) // ~10 req/min + rl.Cleanup() + + r := chi.NewRouter() + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RealIP) + r.Use(sessionManager.LoadAndSave) + + base := strings.TrimRight(cfg.BasePath, "/") + + r.Route(base+"/api", func(api chi.Router) { + api.Group(func(g chi.Router) { + g.Use(mw.RateLimit(rl)) + g.Post("/auth/register", authH.Register) + g.Post("/auth/login", authH.Login) + }) + api.Post("/auth/logout", authH.Logout) + api.Get("/auth/me", authH.Me) + api.Group(func(g chi.Router) { + g.Use(mw.AuthRequired(sessionManager)) + g.Use(mw.LoadUserID(sessionManager)) + g.Get("/sync", syncH.Get) + g.Put("/sync", syncH.Put) + }) + }) + + // servir frontend + workDir, _ := os.Getwd() + webDir := filepath.Join(workDir, "web") + fileServer := http.FileServer(http.Dir(webDir)) + + r.Get(base, func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, filepath.Join(webDir, "index.html")) + }) + r.Get(base+"/", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, filepath.Join(webDir, "index.html")) + }) + r.Get(base+"/*", func(w http.ResponseWriter, r *http.Request) { + // strip base path + p := strings.TrimPrefix(r.URL.Path, base) + if p == "" { + p = "/" + } + // tenta servir arquivo + full := filepath.Join(webDir, filepath.Clean(strings.TrimPrefix(p, "/"))) + if st, err := os.Stat(full); err == nil && !st.IsDir() { + http.StripPrefix(base, fileServer).ServeHTTP(w, r) + return + } + http.ServeFile(w, r, filepath.Join(webDir, "index.html")) + }) + + log.Printf("Servidor rodando em :%s base=%s", cfg.Port, base) + log.Fatal(http.ListenAndServe(":"+cfg.Port, r)) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d7f99b8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +services: + initpage: + build: . + container_name: initpage + ports: + - "8080:8080" + volumes: + - ./web:/app/web + - ./data:/app/data + env_file: + - .env + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..061a35b --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module github.com/weltonmoura/initpage + +go 1.22 + +require ( + github.com/alexedwards/scs/v2 v2.7.0 + github.com/go-chi/chi/v5 v5.1.0 + github.com/joho/godotenv v1.5.1 + golang.org/x/crypto v0.25.0 + golang.org/x/time v0.6.0 + modernc.org/sqlite v1.32.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0e378c5 --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +github.com/alexedwards/scs/v2 v2.7.0 h1:DY4rqLCM7UIR9iwxFS0++z1NhTzQlKV30aMHkJCDWKw= +github.com/alexedwards/scs/v2 v2.7.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= +golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s= +modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..6668ff0 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,65 @@ +package auth + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +func HashPassword(password string) ([]byte, error) { + // Argon2id params simples/seguros + const ( + time = 1 + memory = 64 * 1024 + threads = 4 + keyLen = 32 + ) + salt := []byte{} // em produção use salt aleatório; simplificado aqui? vamos melhorar + // Melhor: gerar salt + return argon2.IDKey([]byte(password), salt, time, memory, threads, keyLen), nil +} + +// versão melhor com salt +func HashPasswordSafe(password string) (string, error) { + if password == "" { + return "", errors.New("senha vazia") + } + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + hash := argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32) + return fmt.Sprintf("$argon2id$v=19$m=65536,t=1,p=4$%s$%s", + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(hash)), nil +} + +func VerifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) < 6 { + return false + } + // partes: ["", "argon2id", "v=19", "m=65536,t=1,p=4", salt, hash] + saltB, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + hashB, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + cmp := argon2.IDKey([]byte(password), saltB, 1, 64*1024, 4, uint32(len(hashB))) + if len(cmp) != len(hashB) { + return false + } + for i := range cmp { + if cmp[i] != hashB[i] { + return false + } + } + return true +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f425075 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,34 @@ +package config + +import ( + "os" +) + +type Config struct { + Port string + BasePath string + AppOrigin string + SessionSecret string + TurnstileSecret string + TurnstileSiteKey string + DBPath string +} + +func Load() *Config { + return &Config{ + Port: getEnv("PORT", "8080"), + BasePath: getEnv("BASE_PATH", "/initpage"), + AppOrigin: getEnv("APP_ORIGIN", "https://apps.weltonmoura.com.br"), + SessionSecret: getEnv("SESSION_SECRET", "dev-secret-change-me"), + TurnstileSecret: getEnv("TURNSTILE_SECRET", ""), + TurnstileSiteKey: getEnv("TURNSTILE_SITEKEY", ""), + DBPath: getEnv("DB_PATH", "./data/initpage.db"), + } +} + +func getEnv(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..6253e74 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,41 @@ +package db + +import ( + "database/sql" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +func Init(path string) (*sql.DB, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)") + if err != nil { + return nil, err + } + if _, err := db.Exec(migrations); err != nil { + return nil, err + } + return db, nil +} + +const migrations = ` +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email_verified BOOLEAN DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_login_at DATETIME +); + +CREATE TABLE IF NOT EXISTS user_sync ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + data TEXT NOT NULL, + version INTEGER DEFAULT 1, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +` diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..926efd7 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,121 @@ +package handlers + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/alexedwards/scs/v2" + + "github.com/weltonmoura/initpage/internal/auth" + "github.com/weltonmoura/initpage/internal/config" +) + +type Auth struct { + DB *sql.DB + SM *scs.SessionManager + Cfg *config.Config +} + +type authReq struct { + Email string `json:"email"` + Password string `json:"password"` + Honeypot string `json:"website"` + Token string `json:"cf_turnstile_response"` +} + +func (h *Auth) Register(w http.ResponseWriter, r *http.Request) { + var req authReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"dados inválidos"}`)) + return + } + if req.Honeypot != "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"rejeitado"}`)) + return + } + email := strings.ToLower(strings.TrimSpace(req.Email)) + if !strings.Contains(email, "@") || len(req.Password) < 6 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"email ou senha inválidos"}`)) + return + } + // Turnstile opcional + if h.Cfg.TurnstileSecret != "" && req.Token != "" { + // validação simples - implementar se necessário + } + + hash, err := auth.HashPasswordSafe(req.Password) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"erro ao criar conta"}`)) + return + } + id := randHex(16) + _, err = h.DB.Exec(`INSERT INTO users(id,email,password_hash,created_at) VALUES(?,?,?,?)`, id, email, hash, time.Now()) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"email já cadastrado ou inválido"}`)) + return + } + h.SM.Put(r.Context(), "user_id", id) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) +} + +func (h *Auth) Login(w http.ResponseWriter, r *http.Request) { + var req authReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"dados inválidos"}`)) + return + } + email := strings.ToLower(strings.TrimSpace(req.Email)) + var id, ph string + err := h.DB.QueryRow(`SELECT id,password_hash FROM users WHERE email=?`, email).Scan(&id, &ph) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"email ou senha inválidos"}`)) + return + } + if !auth.VerifyPassword(ph, req.Password) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"email ou senha inválidos"}`)) + return + } + _, _ = h.DB.Exec(`UPDATE users SET last_login_at=? WHERE id=?`, time.Now(), id) + h.SM.Put(r.Context(), "user_id", id) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) +} + +func (h *Auth) Logout(w http.ResponseWriter, r *http.Request) { + h.SM.Destroy(r.Context()) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) +} + +func (h *Auth) Me(w http.ResponseWriter, r *http.Request) { + if !h.SM.Exists(r.Context(), "user_id") { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"logged":false}`)) + return + } + uid := h.SM.Get(r.Context(), "user_id").(string) + var email string + _ = h.DB.QueryRow(`SELECT email FROM users WHERE id=?`, uid).Scan(&email) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"logged": true, "email": email}) +} + +func randHex(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..df2eace --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,45 @@ +package middleware + +import ( + "context" + "net/http" + + "github.com/alexedwards/scs/v2" +) + +type ctxKey string + +const UserIDKey ctxKey = "user_id" + +func AuthRequired(sm *scs.SessionManager) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !sm.Exists(r.Context(), "user_id") { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"não autenticado"}`)) + return + } + next.ServeHTTP(w, r) + }) + } +} + +func LoadUserID(sm *scs.SessionManager) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uid, ok := sm.Get(r.Context(), "user_id").(string) + if ok { + r = r.WithContext(context.WithValue(r.Context(), UserIDKey, uid)) + } + next.ServeHTTP(w, r) + }) + } +} + +func GetUserID(ctx context.Context) string { + v := ctx.Value(UserIDKey) + if s, ok := v.(string); ok { + return s + } + return "" +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..46a51d7 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "golang.org/x/time/rate" +) + +type IPRateLimiter struct { + mu sync.RWMutex + limiters map[string]*rate.Limiter + r rate.Limit + b int +} + +func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter { + return &IPRateLimiter{ + limiters: make(map[string]*rate.Limiter), + r: r, + b: b, + } +} + +func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter { + i.mu.Lock() + defer i.mu.Unlock() + if lim, ok := i.limiters[ip]; ok { + return lim + } + lim := rate.NewLimiter(i.r, i.b) + i.limiters[ip] = lim + return lim +} + +func (i *IPRateLimiter) Cleanup() { + ticker := time.NewTicker(time.Minute) + go func() { + for range ticker.C { + i.mu.Lock() + for k := range i.limiters { + delete(i.limiters, k) + } + i.mu.Unlock() + } + }() +} + +func RateLimit(l *IPRateLimiter) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := r.RemoteAddr + if xf := r.Header.Get("X-Forwarded-For"); xf != "" { + // simples, suficiente pra começar + ip = xf + } + if !l.GetLimiter(ip).Allow() { + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte(`{"error":"muitas tentativas, tente novamente mais tarde"}`)) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go new file mode 100644 index 0000000..a955ad4 --- /dev/null +++ b/internal/sync/sync.go @@ -0,0 +1,91 @@ +package sync + +import ( + "database/sql" + "encoding/json" + "net/http" + "time" + + mw "github.com/weltonmoura/initpage/internal/middleware" +) + +type Sync struct { + DB *sql.DB +} + +type syncPayload struct { + Data json.RawMessage `json:"data"` + Version int `json:"version"` +} + +func (s *Sync) Get(w http.ResponseWriter, r *http.Request) { + uid := mw.GetUserID(r.Context()) + if uid == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + var data string + var version int + var updatedAt string + _ = s.DB.QueryRow(`SELECT data, version, updated_at FROM user_sync WHERE user_id=?`, uid).Scan(&data, &version, &updatedAt) + if data == "" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":null,"version":0}`)) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"data":` + data + `,"version":` + itoa(version) + `}`)) +} + +func (s *Sync) Put(w http.ResponseWriter, r *http.Request) { + uid := mw.GetUserID(r.Context()) + if uid == "" { + w.WriteHeader(http.StatusUnauthorized) + return + } + var p syncPayload + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"json inválido"}`)) + return + } + if len(p.Data) > 256*1024 { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"dados muito grandes"}`)) + return + } + // valida JSON básico + if !json.Valid(p.Data) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"error":"json inválido"}`)) + return + } + _, err := s.DB.Exec(`INSERT INTO user_sync(user_id,data,version,updated_at) VALUES(?,?,?,?) + ON CONFLICT(user_id) DO UPDATE SET data=excluded.data, version=excluded.version, updated_at=excluded.updated_at`, + uid, string(p.Data), p.Version, time.Now()) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true}`)) +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + sign := "" + if i < 0 { + sign = "-" + i = -i + } + b := [12]byte{} + n := len(b) + for i > 0 { + n-- + b[n] = byte('0' + i%10) + i /= 10 + } + return sign + string(b[n:]) +} diff --git a/web/ads.js b/web/ads.js new file mode 100644 index 0000000..568a433 --- /dev/null +++ b/web/ads.js @@ -0,0 +1,153 @@ +window.InitPageAds = [ + { + matchCategory: "Produtividade", + name: "Semana Flow", + url: "https://renderpage.weltonmoura.com.br/?page=1izF8H4Gefbw4y6YwxVFO2ituUNOCrRlK", + desc: "Flow da semana em quadro Kanbam.", + icon: "https://play-lh.googleusercontent.com/4fzfggNueiSzUQbpVGLQDP3CX0T4KBvt4OvxddZWz24IYx2b4H1DOKtijG5od_g7WPWOF3IV-nTrkcH8HhNf", + cover: "https://cdn-icons-png.flaticon.com/512/645/645939.png", + clickEndpoint: null + }, + { + matchCategory: "Lazer", + name: "Raul Rock Bar", + url: "https://raulrockbar.com.br/", + desc: "O melhor bar alternativo do Cariri🍻", + icon: "https://raulrockbar.com.br/assets/logo.jpeg", + cover: "https://raulrockbar.com.br/assets/logo.jpeg", + clickEndpoint: null + } +]; + +(function () { + const ADS = window.InitPageAds || []; + + function safeUrl(url) { + try { + const u = new URL(url); + if (u.protocol === "https:" || u.protocol === "http:") return u.href; + } catch (err) { } + return null; + } + + function escapeHtml(str) { + return String(str == null ? "" : str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function getIconFallback(ad) { + return safeUrl(ad.icon) || `https://www.google.com/s2/favicons?domain=${encodeURIComponent(ad.url)}&sz=128`; + } + + function getCoverHtml(ad) { + const cover = safeUrl(ad.cover); + if (!cover) { + return '
' + + '' + + "
"; + } + return `${escapeHtml(ad.name)}`; + } + + function buildCard(ad, layout, cardSize) { + const card = document.createElement("div"); + card.className = "group relative glass-card rounded-2xl overflow-hidden cursor-pointer select-none"; + card.setAttribute("role", "link"); + card.setAttribute("tabindex", "0"); + card.setAttribute("aria-label", `Conteúdo patrocinado: ${ad.name}`); + card.dataset.sponsored = "true"; + card.title = "Patrocinado"; + + const icon = escapeHtml(getIconFallback(ad)); + const name = escapeHtml(ad.name); + const desc = escapeHtml(ad.desc || ""); + + if (layout === "dock") { + card.classList.add("p-2.5", "flex", "flex-col", "items-center", "w-20"); + card.innerHTML = ` +
+ AD + +
+ ${name} + `; + } else if (layout === "list") { + card.classList.add("flex", "items-center", "p-3", "gap-4"); + card.innerHTML = ` +
+ ${getCoverHtml(ad)} + +
+
+
+

${name}

+ Patrocinado +
+

${desc}

+
+ `; + } else { + const bannerHeight = cardSize === "sm" + ? "h-16 sm:h-20" + : cardSize === "lg" + ? "h-32 sm:h-36" + : "h-24 sm:h-28"; + card.innerHTML = ` +
+ ${getCoverHtml(ad)} +
+
Patrocinado
+
+ +
+
+
+

${name}

+

${desc}

+
+ `; + } + + const url = safeUrl(ad.url); + const open = () => { + if (ad.clickEndpoint && navigator.sendBeacon) { + try { + navigator.sendBeacon(ad.clickEndpoint, new Blob([JSON.stringify({ ad: ad.url, t: Date.now() })], { type: "application/json" })); + } catch (err) { } + } + if (url) window.open(url, "_blank", "noopener,noreferrer"); + }; + card.addEventListener("click", open); + card.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + open(); + } + }); + + return card; + } + + function matchingAd(categoryName) { + if (!categoryName) return null; + const target = String(categoryName).trim().toLowerCase(); + return ADS.find(a => target.includes(String(a.matchCategory || "").trim().toLowerCase())) || null; + } + + function renderSponsored(detail) { + if (!detail || detail.activeTabId === "all") return; + const container = document.getElementById("shortcuts-container"); + if (!container) return; + const ad = matchingAd(detail.activeTabName); + if (!ad) return; + container.appendChild(buildCard(ad, detail.layout || "grid", detail.cardSize || "md")); + } + + window.addEventListener("initpage:shortcuts-rendered", (e) => { + renderSponsored((e && e.detail) || {}); + }); +})(); \ No newline at end of file diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..6081cd3 --- /dev/null +++ b/web/app.js @@ -0,0 +1,2392 @@ +"use strict"; + +// Configuração e Estado Inicial +const DEFAULT_CONFIG = { + userName: "Explorador", + city: "", + bgUrl: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1920&q=85", + bgDim: 60, + accentColor: "#3b82f6", + accentHover: "#2563eb", + searchEngine: "google", + layout: "grid", + cardSize: "md", + activeTab: "all", + updatedAt: 0, + categories: [ + { id: "cat-general", name: "Geral" }, + { id: "cat-media", name: "Mídia & Vídeos" }, + { id: "cat-dev", name: "Dev & IA" }, + { id: "cat-productivity", name: "Produtividade" } + ], + shortcuts: [ + { + id: "1", + name: "YouTube", + url: "https://youtube.com", + desc: "Vídeos, músicas e canais", + cover: "https://images.unsplash.com/photo-1611162617474-5b21e879e113?w=600&q=80", + icon: "https://www.youtube.com/s/desktop/f1e71ee6/img/favicon_144x144.png", + categoryId: "cat-media" + }, + { + id: "2", + name: "GitHub", + url: "https://github.com", + desc: "Repositórios e projetos de código", + cover: "https://images.unsplash.com/photo-1618401471353-b98afee0b2eb?w=600&q=80", + icon: "https://github.githubassets.com/favicons/favicon.png", + categoryId: "cat-dev" + }, + { + id: "3", + name: "ChatGPT", + url: "https://chatgpt.com", + desc: "Assistente de inteligência artificial", + cover: "https://images.unsplash.com/photo-1677442136019-21780ecad995?w=600&q=80", + icon: "https://oaistatic-cdn.azureedge.net/chatgpt/favicon.ico", + categoryId: "cat-dev" + }, + { + id: "4", + name: "Reddit", + url: "https://reddit.com", + desc: "Comunidades e novidades do mundo", + cover: "https://images.unsplash.com/photo-1542751371-adc38448a05e?w=600&q=80", + icon: "https://www.redditstatic.com/shreddit/assets/favicon/192x192.png", + categoryId: "cat-media" + }, + { + id: "5", + name: "Wikipedia", + url: "https://pt.wikipedia.org", + desc: "A enciclopédia livre", + cover: "https://images.unsplash.com/photo-1457369804613-52c61a468e7d?w=600&q=80", + icon: "https://pt.wikipedia.org/static/favicon/wikipedia.ico", + categoryId: "cat-general" + }, + { + id: "6", + name: "Notion", + url: "https://notion.so", + desc: "Notas, tarefas e produtividade", + cover: "https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?w=600&q=80", + icon: "https://www.notion.so/images/favicon.ico", + categoryId: "cat-productivity" + } + ] +}; + +DEFAULT_CONFIG.tasks = []; +DEFAULT_CONFIG.notes = []; + +const SEARCH_ENGINES = { + google: { label: "Google", icon: "🌐" }, + duckduckgo: { label: "DuckDuckGo", icon: "🦆" }, + bing: { label: "Bing", icon: "🔎" }, + brave: { label: "Brave", icon: "🦁" }, + youtube: { label: "YouTube", icon: "▶️" }, + github: { label: "GitHub", icon: "🐙" } +}; + +const STORAGE_KEY = "custom_browser_homepage_data_v4"; +let state = { ...DEFAULT_CONFIG }; +let shortcutToDeleteId = null; +let categoryToDeleteId = null; + +// Sincronização na Nuvem +const API_BASE = "api"; +let authUser = null; +let syncTimer = null; +let pendingCloudData = null; + +// Drag & Drop +let draggedShortcutId = null; +let draggedCategoryId = null; + +// Contador para IDs únicos +let _idCounter = 0; +function generateId() { + return "id-" + Date.now().toString(36) + "-" + (++_idCounter).toString(36); +} + +// Sanitização XSS +const _escapeMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; +function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(/[&<>"']/g, c => _escapeMap[c]); +} + +// Validação de URL segura +function sanitizeUrl(url) { + if (!url || typeof url !== 'string') return ''; + const trimmed = url.trim(); + if (/^(javascript|data|vbscript):/i.test(trimmed)) return ''; + try { + const u = new URL(trimmed.startsWith('//') ? 'https:' + trimmed : trimmed); + if (!['http:', 'https:'].includes(u.protocol)) return ''; + return u.href; + } catch { + if (/^https?:\/\//i.test(trimmed)) return ''; + if (/^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/.*)?$/i.test(trimmed)) return 'https://' + trimmed; + return ''; + } +} + +// Higieniza dados vindos de fonte externa (localStorage/JSON) + +function sanitizeState(data) { + if (!data || typeof data !== 'object') return data; + const clean = { ...data }; + if (Array.isArray(clean.shortcuts)) { + clean.shortcuts = clean.shortcuts.map(s => ({ + ...(typeof s === 'object' && s !== null ? s : {}), + url: sanitizeUrl(s && s.url) || 'https://example.com', + cover: sanitizeUrl(s && s.cover), + icon: sanitizeUrl(s && s.icon), + name: (s && s.name) || 'Sem nome', + desc: (s && s.desc) || '' + })); + } + if (Array.isArray(clean.categories)) { + clean.categories = clean.categories + .filter(c => c && typeof c === 'object' && typeof c.id === 'string' && c.id.trim() !== '') + .map(c => ({ id: c.id, name: (c.name || 'Categoria').toString().slice(0, 60) })); + } + // Sanitizar tarefas + if (Array.isArray(clean.tasks)) { + clean.tasks = clean.tasks.map(t => { + const obj = (t && typeof t === 'object') ? t : {}; + return { + id: (typeof obj.id === 'string' && obj.id) ? obj.id : generateId(), + name: (obj.name || '').toString().slice(0, 120), + desc: (obj.desc || '').toString(), + date: (typeof obj.date === 'string') ? obj.date : '', + priority: (['low','medium','high'].includes(obj.priority)) ? obj.priority : 'medium', + checklist: Array.isArray(obj.checklist) ? obj.checklist.map(it => ({ + id: (typeof it.id === 'string' && it.id) ? it.id : generateId(), + text: (it.text || '').toString().slice(0, 200), + done: !!it.done + })) : [], + createdAt: (typeof obj.createdAt === 'number') ? obj.createdAt : Date.now() + }; + }); + } + // Sanitizar notas + if (Array.isArray(clean.notes)) { + clean.notes = clean.notes.map(n => { + const obj = (n && typeof n === 'object') ? n : {}; + return { + id: (typeof obj.id === 'string' && obj.id) ? obj.id : generateId(), + name: (obj.name || '').toString().slice(0, 80), + desc: (obj.desc || '').toString(), + createdAt: (typeof obj.createdAt === 'number') ? obj.createdAt : Date.now() + }; + }); + } + if (clean.userName && typeof clean.userName === 'string') clean.userName = clean.userName.slice(0, 60); + if (clean.city && typeof clean.city === 'string') clean.city = clean.city.slice(0, 60); + if (typeof clean.bgUrl === 'string') { + clean.bgUrl = /^https?:\/\//i.test(clean.bgUrl) ? clean.bgUrl.replace(/["'\\]/g, '') : ''; + } + if (typeof clean.searchEngine !== 'string' || !SEARCH_ENGINES[clean.searchEngine]) clean.searchEngine = 'google'; + if (!['grid', 'dock', 'list'].includes(clean.layout)) clean.layout = 'grid'; + if (!['sm', 'md', 'lg'].includes(clean.cardSize)) clean.cardSize = 'md'; + if (typeof clean.updatedAt !== 'number' || !Number.isFinite(clean.updatedAt)) clean.updatedAt = 0; + return clean; +} + + +// Variáveis do Calendário +let calendarCurrentDate = new Date(); + +// Focus Trap para modais (acessibilidade) +const MODAL_SELECTORS = ["#modal-shortcut", "#modal-categories", "#modal-settings", "#modal-paste-json", "#modal-delete-confirm", "#modal-delete-cat-confirm"]; +function trapFocus(e) { + if (e.key !== "Tab") return; + const openModal = MODAL_SELECTORS + .map(sel => document.querySelector(sel)) + .find(m => m && !m.classList.contains("hidden")); + if (!openModal) return; + + const focusables = openModal.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } +} + +// ------------------------------------------------------------- +// PERSISTÊNCIA LOCAL (LocalStorage) +// ------------------------------------------------------------- +function loadState() { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + state = { ...DEFAULT_CONFIG, ...sanitizeState(parsed) }; + if (!Array.isArray(state.categories) || state.categories.length === 0) { + state.categories = [...DEFAULT_CONFIG.categories]; + } + } + } catch (err) { + console.warn("Falha ao ler dados locais:", err); + } +} + +function saveState() { + try { + state.updatedAt = Date.now(); + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (err) { + console.warn("Falha ao salvar dados locais:", err); + } + try { updateTasksBadge(); } catch (e) {} +} + +// ------------------------------------------------------------- +// FEEDBACK TOAST +// ------------------------------------------------------------- +let toastTimeout; +function showToast(msg, iconName = "info") { + const toast = document.getElementById("toast"); + const toastMsg = document.getElementById("toast-msg"); + const toastIcon = document.getElementById("toast-icon"); + + toastMsg.innerText = msg; + toastIcon.setAttribute("data-lucide", iconName); + lucide.createIcons(); + + toast.classList.remove("translate-y-16", "opacity-0", "pointer-events-none"); + clearTimeout(toastTimeout); + toastTimeout = setTimeout(() => { + toast.classList.add("translate-y-16", "opacity-0", "pointer-events-none"); + }, 3000); +} + +// ------------------------------------------------------------- +// RELÓGIO E DATA INTEGRADOS +// ------------------------------------------------------------- +function updateClockAndGreeting() { + const now = new Date(); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + document.getElementById("clock-display").innerText = `${hours}:${minutes}`; + + const options = { weekday: 'long', day: 'numeric', month: 'long' }; + const dateFormatted = now.toLocaleDateString('pt-BR', options); + document.getElementById("header-date").innerText = dateFormatted.charAt(0).toUpperCase() + dateFormatted.slice(1); + + const h = now.getHours(); + let greeting = "Olá"; + if (h >= 5 && h < 12) greeting = "Bom dia"; + else if (h >= 12 && h < 18) greeting = "Boa tarde"; + else if (h >= 18 && h < 24) greeting = "Boa noite"; + else greeting = "Boa madrugada"; + + document.getElementById("greeting-text").innerText = greeting; + document.getElementById("user-display-name").innerText = state.userName || "Visitante"; +} + +// ------------------------------------------------------------- +// CLIMA (Open-Meteo, gratuito e sem API key) +// ------------------------------------------------------------- +const WEATHER_CACHE_KEY = "initpage_weather_cache_v1"; +const WEATHER_TTL = 30 * 60 * 1000; + +function weatherIconForCode(code) { + const c = Number(code); + if (c === 0) return "☀️"; + if (c <= 2) return "🌤️"; + if (c === 3) return "☁️"; + if (c === 45 || c === 48) return "🌫️"; + if (c >= 95) return "⛈️"; + if (c >= 71 && c <= 77) return "❄️"; + if (c >= 51 && c <= 67) return "🌦️"; + if (c >= 80 && c <= 82) return "🌧️"; + return "🌡️"; +} + +function getCachedWeather(city) { + try { + const raw = localStorage.getItem(WEATHER_CACHE_KEY); + if (!raw) return null; + const cached = JSON.parse(raw); + if (cached.city === city && cached.at && Date.now() - cached.at < WEATHER_TTL) { + return cached; + } + } catch (err) { } + return null; +} + +function setCachedWeather(city, data) { + try { + localStorage.setItem(WEATHER_CACHE_KEY, JSON.stringify({ city, at: Date.now(), ...data })); + } catch (err) { } +} + +function renderWeather(data) { + const el = document.getElementById("weather-info"); + if (!data) { + el.classList.add("hidden"); + return; + } + document.getElementById("weather-icon").innerText = data.icon; + document.getElementById("weather-temp").innerText = data.temp; + document.getElementById("weather-city").innerText = data.city; + el.classList.remove("hidden"); +} + +async function refreshWeather() { + if (!state.city || !state.city.trim()) { + renderWeather(null); + return; + } + const cityName = state.city.trim(); + + const cached = getCachedWeather(cityName); + if (cached) { + renderWeather(cached); + return; + } + + try { + const geoRes = await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1&language=pt&format=json`); + const geoData = await geoRes.json(); + const found = geoData && geoData.results && geoData.results[0]; + + if (!found) { + sessionStorage.setItem("initpage_weather_failed", "1"); + renderWeather(null); + return; + } + + const wRes = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${found.latitude}&longitude=${found.longitude}¤t=temperature_2m,weather_code`); + const wData = await wRes.json(); + if (!wData || !wData.current) { + renderWeather(null); + return; + } + + const temp = Math.round(wData.current.temperature_2m); + const data = { + icon: weatherIconForCode(wData.current.weather_code), + temp: `${temp}°C`, + city: (found.name || cityName) + }; + setCachedWeather(cityName, data); + renderWeather(data); + } catch (err) { + renderWeather(null); + } +} + +function setupWeather() { + const cached = getCachedWeather((state.city || "").trim()); + if (cached) { + renderWeather(cached); + return; + } + refreshWeather(); +} + +// ------------------------------------------------------------- +// WIDGET DE CALENDÁRIO TRANSLÚCIDO +// ------------------------------------------------------------- +function toggleCalendarWidget() { + const calWidget = document.getElementById("calendar-widget"); + const isHidden = calWidget.classList.contains("hidden"); + + if (isHidden) { + calendarCurrentDate = new Date(); + renderCalendar(); + calWidget.classList.remove("hidden"); + } else { + calWidget.classList.add("hidden"); + } +} + +function renderCalendar() { + const year = calendarCurrentDate.getFullYear(); + const month = calendarCurrentDate.getMonth(); + + const monthName = new Intl.DateTimeFormat('pt-BR', { month: 'long', year: 'numeric' }).format(calendarCurrentDate); + document.getElementById("cal-month-year").innerText = monthName; + + const firstDayIndex = new Date(year, month, 1).getDay(); + const totalDaysInMonth = new Date(year, month + 1, 0).getDate(); + const totalDaysInPrevMonth = new Date(year, month, 0).getDate(); + + const grid = document.getElementById("cal-days-grid"); + grid.innerHTML = ""; + + const today = new Date(); + const isCurrentMonthAndYear = (today.getFullYear() === year && today.getMonth() === month); + + for (let i = firstDayIndex - 1; i >= 0; i--) { + const dayNum = totalDaysInPrevMonth - i; + const cell = document.createElement("div"); + cell.className = "py-1.5 text-slate-500 cursor-default opacity-50 cal-day-cell"; + cell.innerText = dayNum; + grid.appendChild(cell); + } + + window._calendarSelectedDay = window._calendarSelectedDay || today.toISOString().slice(0,10); + + for (let day = 1; day <= totalDaysInMonth; day++) { + const cell = document.createElement("button"); + cell.type = "button"; + const thisDate = new Date(year, month, day); + const dateStr = thisDate.toISOString().slice(0,10); + const isToday = isCurrentMonthAndYear && (day === today.getDate()); + const hasTasks = getTasksForDate(dateStr).length > 0; + const isSelected = window._calendarSelectedDay === dateStr; + + cell.className = `cal-day-cell py-1.5 rounded-xl transition-all duration-150 font-medium relative ${ + isSelected + ? "bg-accent/80 text-white font-bold ring-2 ring-white/40" + : isToday + ? "bg-accent text-white font-bold shadow-md ring-2 ring-white/30" + : "text-slate-200 hover:bg-white/15 hover:text-white" + }`; + cell.innerHTML = `${day}${hasTasks ? '' : ''}`; + cell.dataset.dateStr = dateStr; + + cell.onclick = () => { + window._calendarSelectedDay = dateStr; + const clickedDate = new Date(year, month, day); + const dateStrPt = clickedDate.toLocaleDateString('pt-BR', { weekday: 'short', day: 'numeric', month: 'short' }); + document.getElementById("cal-selected-info").innerText = isToday ? "Hoje" : dateStrPt; + renderCalendar(); + renderCalendarDayTasks(); + }; + + grid.appendChild(cell); + } + + const totalRendered = firstDayIndex + totalDaysInMonth; + const remainingDays = (7 - (totalRendered % 7)) % 7; + for (let day = 1; day <= remainingDays; day++) { + const cell = document.createElement("div"); + cell.className = "py-1.5 text-slate-500 cursor-default opacity-50 cal-day-cell"; + cell.innerText = day; + grid.appendChild(cell); + } + renderCalendarDayTasks(); +} + +function changeCalendarMonth(delta) { + calendarCurrentDate.setMonth(calendarCurrentDate.getMonth() + delta); + renderCalendar(); +} + +// ------------------------------------------------------------- +// TEMA E CORES +// ------------------------------------------------------------- +function applyTheme() { + document.documentElement.style.setProperty('--accent-color', state.accentColor); + document.documentElement.style.setProperty('--accent-hover', state.accentHover); + + const bgCover = document.getElementById("bg-cover"); + bgCover.style.backgroundImage = state.bgUrl + ? `url("${state.bgUrl.replace(/["'\\]/g, '')}")` + : 'none'; + + const dim = state.bgDim || 60; + document.getElementById("bg-overlay").style.backgroundColor = `rgba(0, 0, 0, ${dim / 100})`; + document.getElementById("val-bg-dim").innerText = `${dim}%`; + + document.getElementById("setting-user-name").value = state.userName; + document.getElementById("setting-city").value = state.city || ""; + document.getElementById("setting-bg-url").value = state.bgUrl; + document.getElementById("setting-bg-dim").value = state.bgDim; + document.getElementById("setting-custom-color").value = state.accentColor; + + document.querySelectorAll(".layout-btn").forEach(btn => { + btn.classList.toggle("selected", btn.dataset.layout === state.layout); + }); + + document.querySelectorAll(".size-btn").forEach(btn => { + btn.classList.toggle("selected", btn.dataset.size === state.cardSize); + }); + + updateSearchEngineUI(); +} + +function updateSearchEngineUI() { + const eng = SEARCH_ENGINES[state.searchEngine] || SEARCH_ENGINES.google; + document.getElementById("search-engine-icon").innerText = eng.icon; + document.getElementById("search-engine-label").innerText = eng.label; + document.getElementById("search-input").placeholder = `Buscar no ${eng.label} ou digitar URL...`; +} + +// ------------------------------------------------------------- +// GESTÃO DE ABAS COM BOTÃO '+' AO FINAL +// ------------------------------------------------------------- +function renderTabs() { + const wrapper = document.getElementById("tabs-wrapper"); + if (!wrapper) return; + + // Não re-renderiza abas durante busca + const searchInput = document.getElementById("search-input"); + if (searchInput && searchInput.value && searchInput.value.trim()) { + return; + } + + wrapper.innerHTML = ""; + + const btnAll = createTabButton("all", "Todos", state.activeTab === "all", false); + wrapper.appendChild(btnAll); + + state.categories.forEach(cat => { + const btnCat = createTabButton(cat.id, cat.name, state.activeTab === cat.id, true); + wrapper.appendChild(btnCat); + }); + + const btnAddCat = document.createElement("button"); + btnAddCat.type = "button"; + btnAddCat.title = "Gerenciar ou adicionar categorias"; + btnAddCat.className = "flex items-center justify-center w-7 h-7 rounded-xl glass-card text-slate-300 hover:text-white hover:bg-white/20 transition active:scale-95 flex-shrink-0 ml-1"; + btnAddCat.innerHTML = ''; + btnAddCat.onclick = openCategoriesModal; + wrapper.appendChild(btnAddCat); + + lucide.createIcons(); +} + +function createTabButton(id, label, isActive, isDraggable) { + const tab = document.createElement("button"); + tab.dataset.tabId = id; + tab.className = `tab-btn flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none ${ + isActive + ? "selected" + : "glass-card text-slate-300 hover:text-white hover:bg-white/15" + }`; + + tab.innerHTML = `${escapeHtml(label)}`; + tab.onclick = () => switchTab(id); + + tab.addEventListener("dragover", (e) => { + if (draggedShortcutId && id !== "all") { + e.preventDefault(); + tab.classList.add("tab-drag-over"); + } else if (draggedCategoryId && isDraggable) { + e.preventDefault(); + tab.classList.add("tab-drag-over"); + } + }); + + tab.addEventListener("dragleave", () => { + tab.classList.remove("tab-drag-over"); + }); + + tab.addEventListener("drop", (e) => { + tab.classList.remove("tab-drag-over"); + + if (draggedShortcutId && id !== "all") { + e.preventDefault(); + const shortcut = state.shortcuts.find(s => s.id === draggedShortcutId); + if (shortcut) { + shortcut.categoryId = id; + saveState(); + renderShortcuts(); + populateCategorySelect(); + showToast(`Atalho movido para "${label}"`, "folder"); + scheduleSync(); + } + return; + } + + if (draggedCategoryId && isDraggable && draggedCategoryId !== id) { + e.preventDefault(); + const oldIndex = state.categories.findIndex(c => c.id === draggedCategoryId); + const targetIndex = state.categories.findIndex(c => c.id === id); + if (oldIndex !== -1 && targetIndex !== -1) { + const [movedCat] = state.categories.splice(oldIndex, 1); + state.categories.splice(targetIndex, 0, movedCat); + saveState(); + renderTabs(); + showToast("Ordem das categorias atualizada!", "move"); + } + } + }); + + if (isDraggable) { + tab.draggable = true; + tab.addEventListener("dragstart", (e) => { + draggedCategoryId = id; + e.dataTransfer.setData("text/category-id", id); + tab.classList.add("opacity-50"); + }); + + tab.addEventListener("dragend", () => { + draggedCategoryId = null; + tab.classList.remove("opacity-50"); + }); + } + + return tab; +} + +function switchTab(tabId) { + if (state.activeTab === tabId) return; + state.activeTab = tabId; + const searchInput = document.getElementById("search-input"); + if (searchInput) searchInput.value = ""; + + const container = document.getElementById("shortcuts-container"); + container.classList.add("switching"); + + renderTabs(); + + setTimeout(() => { + renderShortcuts(); + container.classList.remove("switching"); + }, 140); +} + +// ------------------------------------------------------------- +// RENDERIZAÇÃO DOS ATALHOS + CARD '+' AO FINAL +// ------------------------------------------------------------- +function getFilteredShortcuts() { + if (state.activeTab === "all") { + return [...state.shortcuts]; + } + return state.shortcuts.filter(s => s.categoryId === state.activeTab); +} + +function renderShortcuts(searchQuery = "") { + const container = document.getElementById("shortcuts-container"); + const tabsWrapper = document.getElementById("tabs-wrapper"); + if (!container) return; + container.innerHTML = ""; + + const query = (searchQuery || "").trim().toLowerCase(); + let list; + + if (query) { + list = state.shortcuts.filter(s => { + const name = (s.name || "").toLowerCase(); + const desc = (s.desc || "").toLowerCase(); + const url = (s.url || "").toLowerCase(); + return name.includes(query) || desc.includes(query) || url.includes(query); + }); + // Ocultar abas durante a busca + if (tabsWrapper) { + tabsWrapper.classList.add("opacity-40", "pointer-events-none"); + } + } else { + list = getFilteredShortcuts(); + if (tabsWrapper) { + tabsWrapper.classList.remove("opacity-40", "pointer-events-none"); + } + } + + document.getElementById("shortcuts-count").innerText = `${list.length} ${list.length === 1 ? 'atalho' : 'atalhos'}`; + + if (state.layout === "grid") { + container.className = "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3.5 sm:gap-4"; + } else if (state.layout === "dock") { + container.className = "flex flex-wrap items-center justify-center gap-3 py-3 px-5 rounded-3xl glass-panel max-w-fit mx-auto"; + } else if (state.layout === "list") { + container.className = "flex flex-col gap-2.5 max-w-3xl mx-auto"; + } + + list.forEach(item => { + const card = createShortcutCard(item); + container.appendChild(card); + }); + + const addCard = createAddShortcutCard(); + + window.dispatchEvent(new CustomEvent("initpage:shortcuts-rendered", { + detail: { + activeTabId: state.activeTab, + activeTabName: state.activeTab === "all" + ? null + : ((state.categories.find(c => c.id === state.activeTab) || {}).name || null), + layout: state.layout, + cardSize: state.cardSize + } + })); + + container.appendChild(addCard); + + lucide.createIcons(); +} + +function createAddShortcutCard() { + const card = document.createElement("div"); + card.className = "group relative glass-card border-dashed border-white/20 hover:border-accent hover:bg-white/10 rounded-2xl overflow-hidden cursor-pointer select-none flex flex-col items-center justify-center transition-all duration-300"; + + if (state.layout === "grid") { + let bannerHeight = "h-24 sm:h-28"; + if (state.cardSize === "sm") bannerHeight = "h-16 sm:h-20"; + if (state.cardSize === "lg") bannerHeight = "h-32 sm:h-36"; + + card.innerHTML = ` +
+
+ +
+
+
+

Adicionar Atalho

+
+ `; + } else if (state.layout === "dock") { + card.classList.add("p-2.5", "flex", "flex-col", "items-center", "w-20"); + card.innerHTML = ` +
+ +
+ Adicionar + `; + } else if (state.layout === "list") { + card.classList.add("flex", "items-center", "p-4", "gap-4", "justify-center"); + card.innerHTML = ` +
+ +
+ Adicionar novo atalho nesta categoria + `; + } + + card.onclick = () => openShortcutModal(); + return card; +} + +function createShortcutCard(item) { + const card = document.createElement("div"); + card.dataset.id = item.id; + card.draggable = true; + card.className = "group relative glass-card rounded-2xl overflow-hidden cursor-pointer select-none"; + + const safeIcon = sanitizeUrl(item.icon) || `https://www.google.com/s2/favicons?domain=${encodeURIComponent(item.url)}&sz=128`; + const safeCover = sanitizeUrl(item.cover); + const safeName = escapeHtml(item.name); + const safeDesc = escapeHtml(item.desc || item.url.replace(/^https?:\/\//, '')); + + const hasCover = !!safeCover; + const coverHtml = hasCover + ? `${safeName}` + : `
+ +
`; + + const categoryObj = state.categories.find(c => c.id === item.categoryId); + const categoryName = escapeHtml(categoryObj ? categoryObj.name : "Geral"); + + if (state.layout === "grid") { + let bannerHeight = "h-24 sm:h-28"; + if (state.cardSize === "sm") bannerHeight = "h-16 sm:h-20"; + if (state.cardSize === "lg") bannerHeight = "h-32 sm:h-36"; + + card.innerHTML = ` +
+ ${coverHtml} +
+ +
+ +
+ +
+ ${categoryName} +
+
+ +
+

${safeName}

+

${safeDesc}

+
+ `; + } else if (state.layout === "dock") { + card.classList.add("p-2.5", "flex", "flex-col", "items-center", "w-20"); + card.innerHTML = ` +
+ +
+ ${safeName} + `; + } else if (state.layout === "list") { + card.classList.add("flex", "items-center", "p-3", "gap-4"); + card.innerHTML = ` +
+ ${coverHtml} +
+ +
+
+
+

${safeName}

+ ${categoryName} +
+

${safeDesc}

+
+ `; + } + + const actions = document.createElement("div"); + actions.className = "card-actions absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity bg-slate-950/80 rounded-lg p-1 border border-white/15 shadow-xl z-20"; + + const btnEdit = document.createElement("button"); + btnEdit.className = "p-1 rounded hover:bg-white/20 text-slate-300 hover:text-white transition"; + btnEdit.title = "Editar"; + btnEdit.setAttribute("aria-label", `Editar atalho ${safeName}`); + btnEdit.innerHTML = ''; + btnEdit.onclick = (e) => { + e.stopPropagation(); + openShortcutModal(item); + }; + + const btnDelete = document.createElement("button"); + btnDelete.className = "p-1 rounded hover:bg-rose-500/20 text-slate-400 hover:text-rose-400 transition"; + btnDelete.title = "Excluir"; + btnDelete.setAttribute("aria-label", `Excluir atalho ${safeName}`); + btnDelete.innerHTML = ''; + btnDelete.onclick = (e) => { + e.stopPropagation(); + askDeleteShortcut(item.id, item.name); + }; + + actions.appendChild(btnEdit); + actions.appendChild(btnDelete); + card.appendChild(actions); + + card.onclick = (e) => { + if (e.target.closest("button")) return; + const target = sanitizeUrl(item.url); + if (target) window.open(target, "_blank", "noopener,noreferrer"); + }; + + card.addEventListener("dragstart", (e) => { + draggedShortcutId = item.id; + e.dataTransfer.setData("text/shortcut-id", item.id); + card.classList.add("card-dragging"); + }); + + card.addEventListener("dragend", () => { + draggedShortcutId = null; + card.classList.remove("card-dragging"); + document.querySelectorAll(".card-drag-over").forEach(el => el.classList.remove("card-drag-over")); + }); + + card.addEventListener("dragover", (e) => { + if (draggedShortcutId && draggedShortcutId !== item.id) { + e.preventDefault(); + card.classList.add("card-drag-over"); + } + }); + + card.addEventListener("dragleave", () => { + card.classList.remove("card-drag-over"); + }); + + card.addEventListener("drop", (e) => { + e.preventDefault(); + card.classList.remove("card-drag-over"); + + if (draggedShortcutId && draggedShortcutId !== item.id) { + const oldIndex = state.shortcuts.findIndex(s => s.id === draggedShortcutId); + const targetIndex = state.shortcuts.findIndex(s => s.id === item.id); + + if (oldIndex !== -1 && targetIndex !== -1) { + const [movedItem] = state.shortcuts.splice(oldIndex, 1); + state.shortcuts.splice(targetIndex, 0, movedItem); + saveState(); + renderShortcuts(); + showToast("Atalho reposicionado!", "move"); + } + } + }); + + return card; +} + +// ------------------------------------------------------------- +// GERENCIAMENTO (CRUD) DE CATEGORIAS +// ------------------------------------------------------------- +function openCategoriesModal() { + renderCategoriesCrudList(); + resetCategoryForm(); + document.getElementById("modal-categories").classList.remove("hidden"); +} + +function closeCategoriesModal() { + document.getElementById("modal-categories").classList.add("hidden"); + resetCategoryForm(); +} + +function resetCategoryForm() { + document.getElementById("input-cat-id").value = ""; + document.getElementById("input-cat-name").value = ""; + document.getElementById("btn-submit-cat-text").innerText = "Adicionar"; + document.getElementById("btn-cancel-cat-edit").classList.add("hidden"); +} + +function renderCategoriesCrudList() { + const container = document.getElementById("categories-crud-list"); + container.innerHTML = ""; + + state.categories.forEach(cat => { + const count = state.shortcuts.filter(s => s.categoryId === cat.id).length; + const row = document.createElement("div"); + row.className = "flex items-center justify-between p-2 rounded-xl glass-panel text-xs hover:bg-white/10 transition"; + + row.innerHTML = ` +
+ + ${escapeHtml(cat.name)} + (${count} atalhos) +
+
+ + +
+ `; + + row.querySelector(".btn-edit-cat").onclick = () => { + document.getElementById("input-cat-id").value = cat.id; + document.getElementById("input-cat-name").value = cat.name; + document.getElementById("btn-submit-cat-text").innerText = "Atualizar"; + document.getElementById("btn-cancel-cat-edit").classList.remove("hidden"); + document.getElementById("input-cat-name").focus(); + }; + + row.querySelector(".btn-del-cat").onclick = () => { + askDeleteCategory(cat.id, cat.name); + }; + + container.appendChild(row); + }); + + lucide.createIcons(); +} + +function handleCategoryFormSubmit(e) { + e.preventDefault(); + const id = document.getElementById("input-cat-id").value; + const name = document.getElementById("input-cat-name").value.trim(); + + if (!name) return; + + if (id) { + state.categories = state.categories.map(c => c.id === id ? { ...c, name } : c); + showToast(`Categoria "${name}" atualizada!`, "check"); + scheduleSync(); + } else { + const newCat = { + id: generateId(), + name + }; + state.categories.push(newCat); + showToast(`Categoria "${name}" criada!`, "plus"); + scheduleSync(); + } + + saveState(); + resetCategoryForm(); + renderCategoriesCrudList(); + renderTabs(); + populateCategorySelect(); +} + +function askDeleteCategory(id, name) { + if (state.categories.length <= 1) { + showToast("Você precisa manter pelo menos uma categoria!", "alert-circle"); + return; + } + categoryToDeleteId = id; + document.getElementById("delete-cat-confirm-text").innerText = `Deseja excluir a categoria "${name}"? Os atalhos associados a ela serão transferidos para outra categoria.`; + document.getElementById("modal-delete-cat-confirm").classList.remove("hidden"); +} + +function confirmDeleteCategory() { + if (!categoryToDeleteId) return; + + const fallbackCat = state.categories.find(c => c.id !== categoryToDeleteId); + const fallbackId = fallbackCat ? fallbackCat.id : "cat-general"; + + state.shortcuts = state.shortcuts.map(s => { + if (s.categoryId === categoryToDeleteId) { + return { ...s, categoryId: fallbackId }; + } + return s; + }); + + if (state.activeTab === categoryToDeleteId) { + state.activeTab = "all"; + } + + state.categories = state.categories.filter(c => c.id !== categoryToDeleteId); + categoryToDeleteId = null; + + saveState(); + document.getElementById("modal-delete-cat-confirm").classList.add("hidden"); + renderCategoriesCrudList(); + renderTabs(); + renderShortcuts(); + populateCategorySelect(); + showToast("Categoria excluída!", "trash-2"); + scheduleSync(); +} + +function populateCategorySelect(selectedId = null) { + const select = document.getElementById("input-shortcut-category"); + if (!select) return; + const prevVal = selectedId || select.value; + select.innerHTML = ""; + + state.categories.forEach(cat => { + const opt = document.createElement("option"); + opt.value = cat.id; + opt.innerText = cat.name; + if (prevVal && prevVal === cat.id) { + opt.selected = true; + } + select.appendChild(opt); + }); +} + +// ------------------------------------------------------------- +// MODAL DE ATALHOS +// ------------------------------------------------------------- +function openShortcutModal(shortcut = null) { + const modal = document.getElementById("modal-shortcut"); + const title = document.getElementById("modal-shortcut-title"); + const inputId = document.getElementById("input-shortcut-id"); + const inputName = document.getElementById("input-shortcut-name"); + const inputUrl = document.getElementById("input-shortcut-url"); + const inputCover = document.getElementById("input-shortcut-cover"); + const inputDesc = document.getElementById("input-shortcut-desc"); + const inputIcon = document.getElementById("input-shortcut-icon"); + + const targetCatId = shortcut ? shortcut.categoryId : (state.activeTab !== "all" ? state.activeTab : null); + populateCategorySelect(targetCatId); + + if (shortcut) { + title.innerHTML = ' Editar Atalho'; + inputId.value = shortcut.id; + inputName.value = shortcut.name; + inputUrl.value = shortcut.url; + inputCover.value = shortcut.cover || ""; + inputDesc.value = shortcut.desc || ""; + inputIcon.value = shortcut.icon || ""; + } else { + title.innerHTML = ' Novo Atalho'; + inputId.value = ""; + inputName.value = ""; + inputUrl.value = ""; + inputCover.value = ""; + inputDesc.value = ""; + inputIcon.value = ""; + } + + modal.classList.remove("hidden"); + lucide.createIcons(); + setTimeout(() => inputName.focus(), 60); +} + +function closeShortcutModal() { + document.getElementById("modal-shortcut").classList.add("hidden"); +} + +function handleShortcutFormSubmit(e) { + e.preventDefault(); + const id = document.getElementById("input-shortcut-id").value; + const name = document.getElementById("input-shortcut-name").value.trim(); + let url = document.getElementById("input-shortcut-url").value.trim(); + const categoryId = document.getElementById("input-shortcut-category").value; + const cover = document.getElementById("input-shortcut-cover").value.trim(); + const desc = document.getElementById("input-shortcut-desc").value.trim(); + const icon = document.getElementById("input-shortcut-icon").value.trim(); + + const safeUrl = sanitizeUrl(url); + if (!safeUrl) { + showToast("Endereço (URL) inválido. Use http:// ou https://", "alert-triangle"); + return; + } + url = safeUrl; + + const safeCover = sanitizeUrl(cover); + const safeIcon = sanitizeUrl(icon); + + if (id) { + state.shortcuts = state.shortcuts.map(s => s.id === id ? { ...s, name, url, categoryId, cover: safeCover, desc, icon: safeIcon } : s); + showToast("Atalho atualizado!", "check"); + } else { + const newShortcut = { + id: generateId(), + name, + url, + categoryId, + cover: safeCover, + desc, + icon: safeIcon + }; + state.shortcuts.push(newShortcut); + showToast("Novo atalho adicionado!", "bookmark-plus"); + } + + saveState(); + renderShortcuts(); + closeShortcutModal(); + showToast("Atalho adicionado!", "bookmark-plus"); + scheduleSync(); +} + +function askDeleteShortcut(id, name) { + shortcutToDeleteId = id; + document.getElementById("delete-confirm-text").innerText = `Deseja realmente remover o atalho "${name}"?`; + document.getElementById("modal-delete-confirm").classList.remove("hidden"); +} + +function closeDeleteModal() { + shortcutToDeleteId = null; + document.getElementById("modal-delete-confirm").classList.add("hidden"); +} + +function confirmDeleteShortcut() { + if (!shortcutToDeleteId) return; + state.shortcuts = state.shortcuts.filter(s => s.id !== shortcutToDeleteId); + saveState(); + renderShortcuts(); + closeDeleteModal(); + showToast("Atalho removido!", "trash-2"); + scheduleSync(); +} + +// ------------------------------------------------------------- +// BUSCADOR WEB +// ------------------------------------------------------------- +function buildSearchUrl(engine, rawQuery) { + const term = rawQuery.trim(); + if (!term) return null; + + const isDirectDomain = /^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}(\/.*)?$/i.test(term); + const hasProtocol = /^https?:\/\//i.test(term); + + if (hasProtocol) return sanitizeUrl(term) || null; + if (isDirectDomain && !term.includes(" ")) return sanitizeUrl('https://' + term) || null; + + const encoded = encodeURIComponent(term); + switch (engine) { + case "google": + return `https://www.google.com/search?q=${encoded}`; + case "duckduckgo": + return `https://duckduckgo.com/?q=${encoded}`; + case "bing": + return `https://www.bing.com/search?q=${encoded}`; + case "brave": + return `https://search.brave.com/search?q=${encoded}`; + case "youtube": + return `https://www.youtube.com/results?search_query=${encoded}`; + case "github": + return `https://github.com/search?q=${encoded}&type=repositories`; + default: + return `https://www.google.com/search?q=${encoded}`; + } +} + +function handleSearchSubmit(e) { + if (e) e.preventDefault(); + const input = document.getElementById("search-input"); + const query = (input.value || "").trim(); + if (!query) return; + + // Se houver resultado exato de atalho? Mantemos comportamento padrão: busca na web + const targetUrl = buildSearchUrl(state.searchEngine, query); + if (targetUrl) { + window.open(targetUrl, "_blank", "noopener,noreferrer"); + } +} + +// ------------------------------------------------------------- +// CONFIGURAÇÕES E EXPORTAÇÃO +// ------------------------------------------------------------- +function openSettingsModal() { + document.getElementById("modal-settings").classList.remove("hidden"); +} + +function closeSettingsModal() { + document.getElementById("modal-settings").classList.add("hidden"); +} + +function exportConfiguration() { + const jsonStr = JSON.stringify(state, null, 2); + const fileName = `minha-pagina-inicial-${new Date().toISOString().slice(0, 10)}.json`; + const file = new File([jsonStr], fileName, { type: "application/json" }); + + if (navigator.canShare && navigator.share && navigator.canShare({ files: [file] })) { + navigator.share({ files: [file], title: "Configurações da página inicial" }) + .then(() => showToast("Configuração exportada com sucesso!", "download")) + .catch(() => {}); + return; + } + + if (/ArcMobile2|Arc Search/i.test(navigator.userAgent)) { + copyConfigurationToClipboard(); + return; + } + + exportConfigurationFallback(file); +} + +function exportConfigurationFallback(file) { + const url = URL.createObjectURL(file); + const downloadAnchor = document.createElement("a"); + downloadAnchor.href = url; + downloadAnchor.download = file.name; + document.body.appendChild(downloadAnchor); + downloadAnchor.click(); + document.body.removeChild(downloadAnchor); + setTimeout(() => URL.revokeObjectURL(url), 60000); + showToast("Configuração exportada com sucesso!", "download"); +} + +function copyConfigurationToClipboard() { + const jsonStr = JSON.stringify(state, null, 2); + const textarea = document.createElement("textarea"); + textarea.value = jsonStr; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.top = "0"; + textarea.style.left = "0"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + textarea.setSelectionRange(0, 99999); + + let ok = false; + try { + ok = document.execCommand("copy"); + } catch (err) { + ok = false; + } + document.body.removeChild(textarea); + + if (ok) { + showToast("JSON copiado! Cole em um arquivo .json", "copy"); + } else if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(jsonStr) + .then(() => showToast("JSON copiado! Cole em um arquivo .json", "copy")) + .catch(() => showToast("Não foi possível copiar.", "alert-triangle")); + } else { + showToast("Não foi possível copiar.", "alert-triangle"); + } +} + +function importConfiguration(event) { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + applyImportedConfiguration(e.target.result); + }; + reader.readAsText(file); + event.target.value = ""; +} + +function applyImportedConfiguration(rawText) { + try { + const imported = JSON.parse(rawText); + if (imported && Array.isArray(imported.shortcuts)) { + state = { ...DEFAULT_CONFIG, ...sanitizeState(imported) }; + if (!Array.isArray(state.categories) || state.categories.length === 0) { + state.categories = [...DEFAULT_CONFIG.categories]; + } + saveState(); + applyAllSettings(); + showToast("Configuração importada com sucesso!", "check"); + scheduleSync(); + } else { + showToast("JSON inválido!", "alert-triangle"); + } + } catch (err) { + showToast("Erro ao ler JSON.", "alert-triangle"); + } +} + +function openPasteJsonModal() { + document.getElementById("input-paste-json").value = ""; + document.getElementById("modal-paste-json").classList.remove("hidden"); + setTimeout(() => document.getElementById("input-paste-json").focus(), 60); +} + +function closePasteJsonModal() { + document.getElementById("modal-paste-json").classList.add("hidden"); +} + +function confirmPasteJson() { + const text = document.getElementById("input-paste-json").value.trim(); + if (!text) { + showToast("Cole o JSON no campo primeiro.", "alert-triangle"); + return; + } + applyImportedConfiguration(text); + closePasteJsonModal(); +} + +function pasteJsonFromClipboard() { + if (navigator.clipboard && navigator.clipboard.readText) { + navigator.clipboard.readText() + .then(text => { + if (!text || !text.trim()) { + openPasteJsonModal(); + return; + } + applyImportedConfiguration(text); + }) + .catch(() => openPasteJsonModal()); + } else { + openPasteJsonModal(); + } +} + +function applyAllSettings() { + applyTheme(); + renderTabs(); + renderShortcuts(); + updateClockAndGreeting(); +} + +// ------------------------------------------------------------- +// EVENT LISTENERS GERAIS +// ------------------------------------------------------------- +window.addEventListener("DOMContentLoaded", () => { + loadState(); + applyAllSettings(); + setupWeather(); + setInterval(updateClockAndGreeting, 1000); + setInterval(refreshWeather, 30 * 60 * 1000); + + // Service Worker (PWA / offline) + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('sw.js').catch(() => {}); + } + + // Fechar modais ao clicar fora (backdrop) + const modalClosers = { + "modal-shortcut": closeShortcutModal, + "modal-categories": closeCategoriesModal, + "modal-settings": closeSettingsModal, + "modal-paste-json": closePasteJsonModal, + "modal-delete-confirm": closeDeleteModal, + "modal-delete-cat-confirm": () => { + categoryToDeleteId = null; + document.getElementById("modal-delete-cat-confirm").classList.add("hidden"); + } + }; + Object.entries(modalClosers).forEach(([id, closer]) => { + const el = document.getElementById(id); + el.addEventListener("click", (e) => { + if (e.target === el) closer(); + }); + }); + + // Atalhos de teclado + window.addEventListener("keydown", (e) => { + trapFocus(e); + + if ((e.key === "/" || (e.ctrlKey && e.key.toLowerCase() === "k")) && document.activeElement.tagName !== "INPUT" && document.activeElement.tagName !== "TEXTAREA") { + e.preventDefault(); + document.getElementById("search-input").focus(); + } + if (e.key === "Escape") { + closeShortcutModal(); + closeSettingsModal(); + closeDeleteModal(); + closeCategoriesModal(); + closePasteJsonModal(); + document.getElementById("modal-delete-cat-confirm").classList.add("hidden"); + document.getElementById("search-engine-menu").classList.add("hidden"); + document.getElementById("calendar-widget").classList.add("hidden"); + } + }); + + // Busca + document.getElementById("search-form").addEventListener("submit", handleSearchSubmit); + const searchInput = document.getElementById("search-input"); + if (searchInput) { + searchInput.addEventListener("input", () => { + renderShortcuts(searchInput.value); + }); + } + + // Menu de Buscadores + const searchEngineBtn = document.getElementById("search-engine-btn"); + const searchEngineMenu = document.getElementById("search-engine-menu"); + + searchEngineBtn.addEventListener("click", (e) => { + e.stopPropagation(); + searchEngineMenu.classList.toggle("hidden"); + }); + + document.addEventListener("click", () => { + searchEngineMenu.classList.add("hidden"); + }); + + document.querySelectorAll(".engine-opt").forEach(btn => { + btn.addEventListener("click", () => { + state.searchEngine = btn.dataset.engine; + saveState(); + updateSearchEngineUI(); + searchEngineMenu.classList.add("hidden"); + showToast(`Buscador: ${SEARCH_ENGINES[state.searchEngine].label}`, "search"); + }); + }); + + // Widget de Calendário Integrado ao Relógio / Saudação + const clockTrigger = document.getElementById("clock-calendar-trigger"); + clockTrigger.addEventListener("click", (e) => { + if (e.target.id === "user-display-name") { + e.stopPropagation(); + openSettingsModal(); + document.getElementById("setting-user-name").focus(); + return; + } + e.stopPropagation(); + toggleCalendarWidget(); + }); + + // Navegação do Calendário + document.getElementById("cal-prev-month").addEventListener("click", (e) => { + e.stopPropagation(); + changeCalendarMonth(-1); + }); + document.getElementById("cal-next-month").addEventListener("click", (e) => { + e.stopPropagation(); + changeCalendarMonth(1); + }); + document.getElementById("cal-btn-today").addEventListener("click", (e) => { + e.stopPropagation(); + calendarCurrentDate = new Date(); + renderCalendar(); + }); + + document.getElementById("calendar-widget").addEventListener("click", (e) => { + e.stopPropagation(); + }); + + // Categorias + document.getElementById("btn-close-categories-modal").addEventListener("click", closeCategoriesModal); + document.getElementById("btn-done-categories").addEventListener("click", closeCategoriesModal); + document.getElementById("form-category").addEventListener("submit", handleCategoryFormSubmit); + document.getElementById("btn-cancel-cat-edit").addEventListener("click", resetCategoryForm); + + document.getElementById("btn-cancel-cat-delete").addEventListener("click", () => { + document.getElementById("modal-delete-cat-confirm").classList.add("hidden"); + }); + document.getElementById("btn-confirm-cat-delete").addEventListener("click", confirmDeleteCategory); + + document.getElementById("btn-shortcut-new-cat").addEventListener("click", () => { + openCategoriesModal(); + }); + + // Atalhos + document.getElementById("btn-close-shortcut-modal").addEventListener("click", closeShortcutModal); + document.getElementById("btn-cancel-shortcut").addEventListener("click", closeShortcutModal); + document.getElementById("form-shortcut").addEventListener("submit", handleShortcutFormSubmit); + + document.getElementById("btn-cancel-delete").addEventListener("click", closeDeleteModal); + document.getElementById("btn-confirm-delete").addEventListener("click", confirmDeleteShortcut); + + // Configurações + document.getElementById("btn-open-settings").addEventListener("click", openSettingsModal); + document.getElementById("btn-close-settings").addEventListener("click", closeSettingsModal); + document.getElementById("btn-save-settings").addEventListener("click", () => { + const nameVal = document.getElementById("setting-user-name").value.trim(); + if (nameVal) state.userName = nameVal; + const cityVal = document.getElementById("setting-city").value.trim(); + const cityChanged = (state.city || "") !== cityVal; + state.city = cityVal; + saveState(); + applyAllSettings(); + closeSettingsModal(); + showToast("Configurações salvas!", "check"); + if (cityChanged) refreshWeather(); + scheduleSync(); + scheduleSync(); + }); + + // Plano de fundo e temas + document.getElementById("btn-apply-bg").addEventListener("click", () => { + const url = document.getElementById("setting-bg-url").value.trim(); + if (url) { + state.bgUrl = url; + saveState(); + applyTheme(); + showToast("Papel de parede atualizado!", "image"); + scheduleSync(); + scheduleSync(); + } + }); + + document.querySelectorAll(".preset-bg").forEach(b => { + b.addEventListener("click", () => { + state.bgUrl = b.dataset.url; + document.getElementById("setting-bg-url").value = state.bgUrl; + saveState(); + applyTheme(); + showToast("Papel de parede atualizado!", "image"); + scheduleSync(); + }); + }); + + document.getElementById("setting-bg-dim").addEventListener("input", (e) => { + state.bgDim = e.target.value; + document.getElementById("val-bg-dim").innerText = `${state.bgDim}%`; + applyTheme(); + }); + document.getElementById("setting-bg-dim").addEventListener("change", () => { + saveState(); + scheduleSync(); + }); + + document.querySelectorAll(".theme-color").forEach(c => { + c.addEventListener("click", () => { + state.accentColor = c.dataset.accent; + state.accentHover = c.dataset.hover; + saveState(); + applyTheme(); + showToast("Cor aplicada!", "palette"); + scheduleSync(); + }); + }); + + document.getElementById("setting-custom-color").addEventListener("change", (e) => { + state.accentColor = e.target.value; + state.accentHover = e.target.value; + saveState(); + applyTheme(); + }); + + document.querySelectorAll(".layout-btn").forEach(btn => { + btn.addEventListener("click", () => { + state.layout = btn.dataset.layout; + saveState(); + applyTheme(); + renderShortcuts(); + }); + }); + + document.querySelectorAll(".size-btn").forEach(btn => { + btn.addEventListener("click", () => { + state.cardSize = btn.dataset.size; + saveState(); + applyTheme(); + renderShortcuts(); + }); + }); + + // Export / Import + document.getElementById("btn-export-json").addEventListener("click", exportConfiguration); + document.getElementById("btn-copy-json").addEventListener("click", copyConfigurationToClipboard); + document.getElementById("btn-paste-json").addEventListener("click", pasteJsonFromClipboard); + document.getElementById("btn-close-paste").addEventListener("click", closePasteJsonModal); + document.getElementById("btn-cancel-paste").addEventListener("click", closePasteJsonModal); + document.getElementById("btn-confirm-paste").addEventListener("click", confirmPasteJson); + document.getElementById("file-import-json").addEventListener("change", importConfiguration); + + attachTasksNotesListeners(); + updateTasksBadge(); + lucide.createIcons(); +}); + +// ------------------------------------------------------------- +// HELPERS DE DATA +// ------------------------------------------------------------- +function formatDatePtBR(dateStr) { + if (!dateStr) return ''; + try { + const d = new Date(dateStr + 'T00:00:00'); + return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' }); + } catch (e) { + return dateStr; + } +} + +function isSameDateStr(a, b) { + return a === b; +} + +// ------------------------------------------------------------- +// TAREFAS +// ------------------------------------------------------------- +function getTasksForDate(dateStr) { + return (state.tasks || []).filter(t => t.date === dateStr).sort((a,b) => { + const pa = { low:0, medium:1, high:2 }[a.priority||'medium']; + const pb = { low:0, medium:1, high:2 }[b.priority||'medium']; + if (pb !== pa) return pb - pa; + return (b.createdAt||0) - (a.createdAt||0); + }); +} + +function getAllTasksSorted() { + return (state.tasks || []).slice().sort((a,b) => { + if (a.date !== b.date) return (a.date||'').localeCompare(b.date||''); + const pa = { low:0, medium:1, high:2 }[a.priority||'medium']; + const pb = { low:0, medium:1, high:2 }[b.priority||'medium']; + if (pb !== pa) return pb - pa; + return (b.createdAt||0) - (a.createdAt||0); + }); +} + +function taskHasOpenItems(t) { + return Array.isArray(t.checklist) && t.checklist.some(it => !it.done); +} + +function renderTasksList() { + const listEl = document.getElementById('tasks-list'); + if (!listEl) return; + const tasks = getAllTasksSorted(); + if (tasks.length === 0) { + listEl.innerHTML = '

Nenhuma tarefa cadastrada.

'; + return; + } + listEl.innerHTML = tasks.map(t => { + const priority = t.priority || 'medium'; + const pColors = { low:'bg-emerald-400/20 text-emerald-300', medium:'bg-amber-400/20 text-amber-300', high:'bg-rose-500/20 text-rose-300' }; + const pLabel = { low:'Baixa', medium:'Média', high:'Alta' }[priority]; + const open = taskHasOpenItems(t); + const doneCount = (t.checklist||[]).filter(it=>it.done).length; + const total = (t.checklist||[]).length; + const checkedDot = open ? '' : 'bg-emerald-400'; + return ` +
+
+
+

+ + ${escapeHtml(t.name || 'Sem nome')} +

+
+ ${pLabel} + ${t.date ? `${formatDatePtBR(t.date)}` : ''} + ${total > 0 ? `${doneCount}/${total}` : ''} +
+
+
+ + +
+
+ ${t.desc ? `

${escapeHtml(t.desc)}

` : ''} +
+ `; + }).join(''); + + lucide.createIcons(); + listEl.querySelectorAll('[data-task-id]').forEach(el => { + const id = el.dataset.taskId; + el.addEventListener('click', (e) => { + if (e.target.closest('.btn-task-edit') || e.target.closest('.btn-task-delete')) return; + openTaskDetail(id); + }); + el.querySelector('.btn-task-edit')?.addEventListener('click', (e) => { + e.stopPropagation(); + openTaskForm(id); + }); + el.querySelector('.btn-task-delete')?.addEventListener('click', (e) => { + e.stopPropagation(); + askDeleteTask(id); + }); + }); +} + +function openTaskPanel() { + renderTasksList(); + document.getElementById('panel-tasks').classList.remove('hidden'); +} + +function closeTaskPanel() { + document.getElementById('panel-tasks').classList.add('hidden'); +} + +function openTaskForm(taskIdOrPrefill = null) { + const form = document.getElementById('form-task'); + const title = document.getElementById('task-form-title'); + const inputId = document.getElementById('input-task-id'); + const inputName = document.getElementById('input-task-name'); + const inputDesc = document.getElementById('input-task-desc'); + const inputDate = document.getElementById('input-task-date'); + const inputPriority = document.getElementById('input-task-priority'); + const checklistEditor = document.getElementById('checklist-editor'); + + checklistEditor.innerHTML = ''; + // Se for string, trata como ID (modo antigo) + if (typeof taskIdOrPrefill === 'string') { + const taskId = taskIdOrPrefill; + const t = (state.tasks || []).find(x => x.id === taskId); + title.innerHTML = ' Editar Tarefa'; + inputId.value = taskId; + inputName.value = t ? t.name || '' : ''; + inputDesc.value = t ? t.desc || '' : ''; + inputDate.value = t ? t.date || '' : ''; + inputPriority.value = t ? t.priority || 'medium' : 'medium'; + if (t && Array.isArray(t.checklist)) { + t.checklist.forEach(it => addCheckItemEditor(it.id, it.text, it.done)); + } + } else { + const prefill = taskIdOrPrefill || {}; + title.innerHTML = ' Nova Tarefa'; + inputId.value = prefill.id || ''; + inputName.value = prefill.name || ''; + inputDesc.value = prefill.desc || ''; + inputDate.value = prefill.date || new Date().toISOString().slice(0,10); + inputPriority.value = prefill.priority || 'medium'; + if (Array.isArray(prefill.checklist)) { + prefill.checklist.forEach(it => addCheckItemEditor(it.id, it.text, it.done)); + } + } + if ((document.getElementById('checklist-editor').children.length) === 0) { + addCheckItemEditor(); + } + document.getElementById('modal-task-form').classList.remove('hidden'); + lucide.createIcons(); + setTimeout(() => inputName.focus(), 50); +} + +function closeTaskForm() { + document.getElementById('modal-task-form').classList.add('hidden'); +} + +function addCheckItemEditor(id = null, text = '', done = false) { + const editor = document.getElementById('checklist-editor'); + const itemId = id || generateId(); + const wrap = document.createElement('div'); + wrap.className = 'check-item'; + if (done) wrap.classList.add('completed'); + wrap.dataset.itemId = itemId; + wrap.innerHTML = ` + + + + `; + editor.appendChild(wrap); + lucide.createIcons(); + wrap.querySelector('.check-done').addEventListener('change', (e) => { + wrap.classList.toggle('completed', e.target.checked); + }); + wrap.querySelector('.check-remove').addEventListener('click', () => { + wrap.remove(); + if (editor.children.length === 0) addCheckItemEditor(); + }); +} + +function collectChecklistFromEditor() { + const items = []; + document.querySelectorAll('#checklist-editor .check-item').forEach(w => { + const done = w.querySelector('.check-done').checked; + const text = w.querySelector('.check-item-text-input').value.trim(); + if (text) { + items.push({ id: w.dataset.itemId || generateId(), text, done }); + } + }); + return items; +} + +function handleTaskSubmit(e) { + e.preventDefault(); + const id = document.getElementById('input-task-id').value; + const name = document.getElementById('input-task-name').value.trim(); + const desc = document.getElementById('input-task-desc').value.trim(); + const date = document.getElementById('input-task-date').value; + const priority = document.getElementById('input-task-priority').value; + const checklist = collectChecklistFromEditor(); + if (!name || !date) return; + if (id) { + state.tasks = (state.tasks || []).map(t => t.id === id ? { ...t, name, desc, date, priority, checklist } : t); + showToast('Tarefa atualizada!', 'check'); + } else { + const nt = { id: generateId(), name, desc, date, priority, checklist, createdAt: Date.now() }; + state.tasks = [...(state.tasks || []), nt]; + showToast('Tarefa adicionada!', 'clipboard-list'); + } + saveState(); + closeTaskForm(); + renderTasksList(); + renderCalendar(); + renderCalendarDayTasks(); + updateTasksBadge(); +} + +function askDeleteTask(id) { + window._taskDeleteId = id; + const t = (state.tasks || []).find(x => x.id === id); + document.getElementById('task-delete-text').innerText = `Deseja excluir "${t ? (t.name||'esta tarefa') : 'esta tarefa'}"?`; + document.getElementById('modal-task-delete').classList.remove('hidden'); +} + +function closeTaskDelete() { + delete window._taskDeleteId; + document.getElementById('modal-task-delete').classList.add('hidden'); +} + +function confirmDeleteTask() { + const id = window._taskDeleteId; + if (!id) return; + state.tasks = (state.tasks || []).filter(t => t.id !== id); + saveState(); + closeTaskDelete(); + renderTasksList(); + renderCalendar(); + renderCalendarDayTasks(); + updateTasksBadge(); + closeTaskDetail(); + showToast('Tarefa excluída!', 'trash-2'); +} + +function openTaskDetail(taskId) { + const t = (state.tasks || []).find(x => x.id === taskId); + const wrap = document.getElementById('task-detail-content'); + if (!t) { closeTaskDetail(); return; } + const priority = t.priority || 'medium'; + const pColors = { low:'bg-emerald-400/20 text-emerald-300', medium:'bg-amber-400/20 text-amber-300', high:'bg-rose-500/20 text-rose-300' }; + const pLabel = { low:'Baixa', medium:'Média', high:'Alta' }[priority]; + const doneCount = (t.checklist||[]).filter(it=>it.done).length; + const total = (t.checklist||[]).length; + wrap.innerHTML = ` +

${escapeHtml(t.name||'Sem nome')}

+
+ ${pLabel} + ${t.date ? `Data: ${formatDatePtBR(t.date)}` : ''} + ${total > 0 ? `Checklist: ${doneCount}/${total}` : ''} +
+ ${t.desc ? `
${escapeHtml(t.desc)}
` : ''} + ${Array.isArray(t.checklist) && t.checklist.length > 0 ? ` +
+ ${t.checklist.map(it => ` +
+ + ${escapeHtml(it.text)} +
+ `).join('')} +
+ ` : ''} +
+ + +
+ `; + document.getElementById('modal-task-detail').classList.remove('hidden'); + lucide.createIcons(); + + wrap.querySelectorAll('.check-done-detail').forEach(cb => { + cb.addEventListener('change', (e) => { + const itemWrap = cb.closest('.check-item'); + const itemId = itemWrap.dataset.itemId; + const idxTask = (state.tasks||[]).findIndex(x => x.id === taskId); + if (idxTask >= 0) { + const t2 = state.tasks[idxTask]; + t2.checklist = (t2.checklist||[]).map(it => it.id === itemId ? { ...it, done: e.target.checked } : it); + state.tasks[idxTask] = t2; + saveState(); + itemWrap.classList.toggle('completed', e.target.checked); + renderTasksList(); + renderCalendar(); + renderCalendarDayTasks(); + updateTasksBadge(); + } + }); + }); + document.getElementById('btn-task-detail-edit').addEventListener('click', () => { + closeTaskDetail(); + openTaskForm(taskId); + }); + document.getElementById('btn-task-detail-delete').addEventListener('click', () => { + closeTaskDetail(); + askDeleteTask(taskId); + }); +} + +function closeTaskDetail() { + document.getElementById('modal-task-detail').classList.add('hidden'); +} + +function updateTasksBadge() { + const dot = document.getElementById('tasks-dot-badge'); + if (!dot) return; + const hasAny = (state.tasks || []).length > 0; + dot.classList.toggle('hidden', !hasAny); +} + +// ------------------------------------------------------------- +// NOTAS +// ------------------------------------------------------------- +function getAllNotesSorted() { + return (state.notes || []).slice().sort((a,b) => (b.createdAt||0) - (a.createdAt||0)); +} + +function renderNotesList() { + const listEl = document.getElementById('notes-list'); + if (!listEl) return; + const notes = getAllNotesSorted(); + if (notes.length === 0) { + listEl.innerHTML = '

Nenhuma nota criada.

'; + return; + } + listEl.innerHTML = notes.map(n => { + const dt = n.createdAt ? new Date(n.createdAt).toLocaleString('pt-BR') : ''; + const desc = (n.desc||'').replace(/\r\n/g, '\n'); + return ` +
+
+
+

${escapeHtml(n.name || 'Sem título')}

+ ${dt ? `${dt}` : ''} +
+
+ + +
+
+ ${desc ? `

${escapeHtml(desc)}

` : ''} +
+ `; + }).join(''); + lucide.createIcons(); + listEl.querySelectorAll('[data-note-id]').forEach(el => { + const id = el.dataset.noteId; + el.addEventListener('click', (e) => { + if (e.target.closest('.btn-note-edit') || e.target.closest('.btn-note-delete')) return; + openNoteForm(id); + }); + el.querySelector('.btn-note-edit')?.addEventListener('click', (e) => { + e.stopPropagation(); + openNoteForm(id); + }); + el.querySelector('.btn-note-delete')?.addEventListener('click', (e) => { + e.stopPropagation(); + askDeleteNote(id); + }); + }); +} + +function openNotesPanel() { + renderNotesList(); + document.getElementById('panel-notes').classList.remove('hidden'); +} + +function closeNotesPanel() { + document.getElementById('panel-notes').classList.add('hidden'); +} + +function openNoteForm(noteId = null) { + const title = document.getElementById('note-form-title'); + const inputId = document.getElementById('input-note-id'); + const inputName = document.getElementById('input-note-name'); + const inputDesc = document.getElementById('input-note-desc'); + const info = document.getElementById('note-created-info'); + if (noteId) { + const n = (state.notes || []).find(x => x.id === noteId); + title.innerHTML = ' Editar Nota'; + inputId.value = noteId; + inputName.value = n ? n.name || '' : ''; + inputDesc.value = n ? n.desc || '' : ''; + if (n && n.createdAt) { + info.innerText = 'Criada em ' + new Date(n.createdAt).toLocaleString('pt-BR'); + info.classList.remove('hidden'); + } else { + info.classList.add('hidden'); + } + } else { + title.innerHTML = ' Nova Nota'; + inputId.value = ''; + inputName.value = ''; + inputDesc.value = ''; + info.classList.add('hidden'); + } + document.getElementById('modal-note-form').classList.remove('hidden'); + lucide.createIcons(); + setTimeout(() => inputName.focus(), 50); +} + +function closeNoteForm() { + document.getElementById('modal-note-form').classList.add('hidden'); +} + +function handleNoteSubmit(e) { + e.preventDefault(); + const id = document.getElementById('input-note-id').value; + const name = document.getElementById('input-note-name').value.trim(); + const desc = document.getElementById('input-note-desc').value.trim(); + if (!name || !desc) return; + if (id) { + state.notes = (state.notes || []).map(n => n.id === id ? { ...n, name, desc } : n); + showToast('Nota atualizada!', 'check'); + } else { + const nn = { id: generateId(), name, desc, createdAt: Date.now() }; + state.notes = [...(state.notes || []), nn]; + showToast('Nota criada!', 'sticky-note'); + } + saveState(); + closeNoteForm(); + renderNotesList(); +} + +function askDeleteNote(id) { + window._noteDeleteId = id; + const n = (state.notes || []).find(x => x.id === id); + document.getElementById('note-delete-text').innerText = `Deseja excluir "${n ? (n.name||'esta nota') : 'esta nota'}"?`; + document.getElementById('modal-note-delete').classList.remove('hidden'); +} + +function closeNoteDelete() { + delete window._noteDeleteId; + document.getElementById('modal-note-delete').classList.add('hidden'); +} + +function confirmDeleteNote() { + const id = window._noteDeleteId; + if (!id) return; + state.notes = (state.notes || []).filter(n => n.id !== id); + saveState(); + closeNoteDelete(); + renderNotesList(); + showToast('Nota excluída!', 'trash-2'); +} + +function renderCalendarDayTasks() { + const wrap = document.getElementById('cal-day-tasks'); + const list = document.getElementById('cal-day-tasks-list'); + if (!wrap || !list) return; + const sel = window._calendarSelectedDay || new Date().toISOString().slice(0,10); + const tasks = getTasksForDate(sel); + if (tasks.length === 0) { + list.innerHTML = ''; + wrap.classList.remove('hidden'); + return; + } + list.innerHTML = tasks.map(t => { + const priority = t.priority || 'medium'; + const pColors = { low:'bg-emerald-400/20 text-emerald-300', medium:'bg-amber-400/20 text-amber-300', high:'bg-rose-500/20 text-rose-300' }; + const pLabel = { low:'Baixa', medium:'Média', high:'Alta' }[priority]; + const open = taskHasOpenItems(t); + const dot = open ? 'bg-amber-400' : 'bg-emerald-400'; + return ``; + }).join(''); + wrap.classList.remove('hidden'); + list.querySelectorAll('[data-task-id]').forEach(btn => { + btn.addEventListener('click', () => openTaskDetail(btn.dataset.taskId)); + }); +} + +// ------------------------------------------------------------- +// EVENT LISTENERS - TAREFAS E NOTAS +// ------------------------------------------------------------- +function attachTasksNotesListeners() { + // Sidebar + document.getElementById('btn-open-tasks')?.addEventListener('click', openTaskPanel); + document.getElementById('btn-open-notes')?.addEventListener('click', openNotesPanel); + + // Tasks panel close + document.getElementById('btn-close-tasks')?.addEventListener('click', closeTaskPanel); + document.querySelector('[data-close-panel="panel-tasks"]')?.addEventListener('click', closeTaskPanel); + + // Task form + document.getElementById('btn-new-task')?.addEventListener('click', () => openTaskForm()); + document.getElementById('btn-close-task-form')?.addEventListener('click', closeTaskForm); + document.getElementById('btn-cancel-task')?.addEventListener('click', closeTaskForm); + document.getElementById('form-task')?.addEventListener('submit', handleTaskSubmit); + document.getElementById('btn-add-check-item')?.addEventListener('click', () => addCheckItemEditor()); + + // Task detail + document.getElementById('btn-close-task-detail')?.addEventListener('click', closeTaskDetail); + + // Task delete + document.getElementById('btn-cancel-task-delete')?.addEventListener('click', closeTaskDelete); + document.getElementById('btn-confirm-task-delete')?.addEventListener('click', confirmDeleteTask); + + // Add task from calendar selected day + document.getElementById('btn-add-task-from-day')?.addEventListener('click', () => { + const sel = window._calendarSelectedDay || new Date().toISOString().slice(0,10); + openTaskForm({ date: sel }); + }); + + // Notes panel close + document.getElementById('btn-close-notes')?.addEventListener('click', closeNotesPanel); + document.querySelector('[data-close-panel="panel-notes"]')?.addEventListener('click', closeNotesPanel); + + // Notes form + document.getElementById('btn-new-note')?.addEventListener('click', () => openNoteForm()); + document.getElementById('btn-close-note-form')?.addEventListener('click', closeNoteForm); + document.getElementById('btn-cancel-note')?.addEventListener('click', closeNoteForm); + document.getElementById('form-note')?.addEventListener('submit', handleNoteSubmit); + + // Note delete + document.getElementById('btn-cancel-note-delete')?.addEventListener('click', closeNoteDelete); + document.getElementById('btn-confirm-note-delete')?.addEventListener('click', confirmDeleteNote); +} + + +// ------------------------------------------------------------- +// SINCRONIZAÇÃO NA NUVEM +// ------------------------------------------------------------- +function scheduleSync() { + if (!authUser) return; + if (syncTimer) clearTimeout(syncTimer); + syncTimer = setTimeout(() => { + pushSync(); + }, 1500); +} + +async function checkAuth() { + try { + const res = await fetch(API_BASE + "/auth/me", { credentials: "include" }); + const data = await res.json(); + if (data.logged) { + authUser = data; + updateSyncUI(); + pullSync(); + } else { + authUser = null; + updateSyncUI(); + } + } catch (e) { + authUser = null; + updateSyncUI(); + } +} + +function updateSyncUI() { + const title = document.getElementById("sync-status-title"); + const sub = document.getElementById("sync-status-sub"); + const badge = document.getElementById("sync-badge"); + const btnLogin = document.getElementById("btn-sync-login"); + const btnSync = document.getElementById("btn-sync-now"); + const btnLogout = document.getElementById("btn-sync-logout"); + + if (authUser) { + if (title) title.innerText = "Conectado"; + if (sub) sub.innerText = authUser.email || "Sincronização ativa"; + if (badge) { badge.classList.remove("hidden"); badge.innerText = "Sincronizado"; } + if (btnLogin) btnLogin.classList.add("hidden"); + if (btnSync) { btnSync.classList.remove("disabled:opacity-40", "disabled:pointer-events-none"); btnSync.disabled = false; } + if (btnLogout) btnLogout.classList.remove("hidden"); + } else { + if (title) title.innerText = "Não conectado"; + if (sub) sub.innerText = "Faça login para sincronizar"; + if (badge) badge.classList.add("hidden"); + if (btnLogin) btnLogin.classList.remove("hidden"); + if (btnSync) { btnSync.classList.add("disabled:opacity-40", "disabled:pointer-events-none"); btnSync.disabled = true; } + if (btnLogout) btnLogout.classList.add("hidden"); + } + if (window.lucide) lucide.createIcons(); +} + +async function pushSync() { + if (!authUser) return; + try { + await fetch(API_BASE + "/sync", { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: state, version: Date.now() }) + }); + const badge = document.getElementById("sync-badge"); + if (badge) { + badge.innerText = "Sincronizado"; + badge.classList.remove("hidden"); + } + } catch (e) {} +} + +async function pullSync() { + if (!authUser) return; + try { + const res = await fetch(API_BASE + "/sync", { credentials: "include" }); + const json = await res.json(); + if (json.data) { + pendingCloudData = json.data; + const m = document.getElementById("modal-sync-merge"); + if (m) m.classList.remove("hidden"); + } + } catch (e) {} +} + +function applyCloudData(data) { + try { + state = { ...DEFAULT_CONFIG, ...sanitizeState(data) }; + if (!Array.isArray(state.categories) || state.categories.length === 0) { + state.categories = [...DEFAULT_CONFIG.categories]; + } + saveState(); + applyAllSettings(); + renderTabs(); + renderShortcuts(); + populateCategorySelect(); + if (document.getElementById("modal-sync-merge")) document.getElementById("modal-sync-merge").classList.add("hidden"); + showToast("Dados da nuvem aplicados!", "cloud"); + } catch (e) { + showToast("Erro ao aplicar dados da nuvem", "alert-circle"); + } +} + +function openAuthModal() { + const m = document.getElementById("modal-auth"); + if (m) m.classList.remove("hidden"); + if (window.lucide) lucide.createIcons(); +} + +function closeAuthModal() { + const m = document.getElementById("modal-auth"); + if (m) m.classList.add("hidden"); +} + + // Sincronização na Nuvem - eventos + document.getElementById("btn-sync-login")?.addEventListener("click", openAuthModal); + document.getElementById("btn-close-auth-modal")?.addEventListener("click", closeAuthModal); + document.getElementById("modal-auth")?.addEventListener("click", (e) => { + if (e.target.id === "modal-auth") closeAuthModal(); + }); + + document.querySelectorAll(".auth-tab-btn").forEach(btn => { + btn.addEventListener("click", () => { + document.querySelectorAll(".auth-tab-btn").forEach(b => { + b.classList.remove("bg-white/15", "text-white"); + b.classList.add("text-slate-300"); + }); + btn.classList.add("bg-white/15", "text-white"); + btn.classList.remove("text-slate-300"); + const tab = btn.dataset.authTab; + document.getElementById("form-login").classList.toggle("hidden", tab !== "login"); + document.getElementById("form-register").classList.toggle("hidden", tab !== "register"); + document.getElementById("login-error").classList.add("hidden"); + document.getElementById("reg-error").classList.add("hidden"); + if (window.lucide) lucide.createIcons(); + }); + }); + + document.getElementById("form-login")?.addEventListener("submit", async (e) => { + e.preventDefault(); + const email = document.getElementById("login-email").value; + const password = document.getElementById("login-password").value; + const errEl = document.getElementById("login-error"); + try { + const res = await fetch(API_BASE + "/auth/login", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }) + }); + const data = await res.json(); + if (!res.ok) { + errEl.innerText = data.error || "Erro ao entrar"; + errEl.classList.remove("hidden"); + return; + } + errEl.classList.add("hidden"); + closeAuthModal(); + await checkAuth(); + showToast("Logado com sucesso!", "log-in"); + pullSync(); + } catch (ex) { + errEl.innerText = "Erro de conexão"; + errEl.classList.remove("hidden"); + } + }); + + document.getElementById("form-register")?.addEventListener("submit", async (e) => { + e.preventDefault(); + const email = document.getElementById("reg-email").value; + const password = document.getElementById("reg-password").value; + const honeypot = document.getElementById("reg-honeypot").value; + const errEl = document.getElementById("reg-error"); + try { + const res = await fetch(API_BASE + "/auth/register", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, website: honeypot }) + }); + const data = await res.json(); + if (!res.ok) { + errEl.innerText = data.error || "Erro ao criar conta"; + errEl.classList.remove("hidden"); + return; + } + errEl.classList.add("hidden"); + closeAuthModal(); + await checkAuth(); + showToast("Conta criada e logada!", "user-plus"); + scheduleSync(); + } catch (ex) { + errEl.innerText = "Erro de conexão"; + errEl.classList.remove("hidden"); + } + }); + + document.getElementById("btn-sync-logout")?.addEventListener("click", async () => { + try { + await fetch(API_BASE + "/auth/logout", { method: "POST", credentials: "include" }); + } catch (e) {} + authUser = null; + updateSyncUI(); + showToast("Desconectado", "log-out"); + }); + + document.getElementById("btn-sync-now")?.addEventListener("click", async () => { + if (!authUser) return; + showToast("Sincronizando...", "refresh-cw"); + await pushSync(); + showToast("Sincronização enviada!", "cloud"); + }); + + document.getElementById("modal-sync-merge")?.addEventListener("click", (e) => { + if (e.target.id === "modal-sync-merge") { + document.getElementById("modal-sync-merge").classList.add("hidden"); + } + }); + document.getElementById("btn-sync-cancel")?.addEventListener("click", () => { + document.getElementById("modal-sync-merge").classList.add("hidden"); + }); + document.getElementById("btn-sync-download")?.addEventListener("click", () => { + if (pendingCloudData) applyCloudData(pendingCloudData); + }); + document.getElementById("btn-sync-upload")?.addEventListener("click", async () => { + document.getElementById("modal-sync-merge").classList.add("hidden"); + showToast("Enviando dados locais...", "upload-cloud"); + await pushSync(); + showToast("Dados enviados para a nuvem!", "cloud"); + }); + + checkAuth(); diff --git a/web/icon.svg b/web/icon.svg new file mode 100644 index 0000000..5f3a1c2 --- /dev/null +++ b/web/icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4db9df3 --- /dev/null +++ b/web/index.html @@ -0,0 +1,899 @@ + + + + + + + InitPage — Seu navegador, personalizado + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+ + Notificação +
+ + + + + +
+
+ + +
+ + +
+ + + + + + + +
+ + +
+
+ + +
+ + + +
+ + + + +
+
+ + +
+ + +
+
+ +
+ +
+ 0 atalhos +
+
+ + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..47c7416 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "InitPage — Sua página inicial", + "short_name": "InitPage", + "description": "Página inicial personalizável com atalhos, categorias, busca e plano de fundo.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#0f172a", + "lang": "pt-BR", + "icons": [ + { + "src": "icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/web/politica-privacidade/index.html b/web/politica-privacidade/index.html new file mode 100644 index 0000000..ea1b46c --- /dev/null +++ b/web/politica-privacidade/index.html @@ -0,0 +1,167 @@ + + + + + + Política de Privacidade · InitPage + + + + + + + + +
+ +
+

+ InitPage · Política de Privacidade +

+ weltonmoura.com.br +
+ +
+
+
+

Última atualização: 18 de outubro de 2026

+

+ Esta Política de Privacidade descreve como o aplicativo web InitPage + (doravante "o aplicativo") coleta, usa e armazena informações. Ao utilizar o aplicativo, você concorda + com as práticas descritas neste documento. +

+
+ +
+

1. Visão geral

+

+ O aplicativo é uma página inicial personalizável que roda inteiramente no navegador. Ele não possui + servidor próprio e não envia seus dados a terceiros. Todo o processamento acontece localmente no seu dispositivo. +

+
+ +
+

2. Dados armazenados localmente

+

+ Suas configurações — como nome de exibição, atalhos, categorias, plano de fundo, cor de destaque e + preferências de layout — são salvas exclusivamente no armazenamento local do seu navegador + (localStorage). +

+

+ Esses dados permanecem no seu dispositivo e podem ser apagados por você a qualquer momento, + pela limpeza de dados do navegador ou pela exportação e exclusão manual do arquivo de configuração. +

+
+ +
+

3. Backup e exportação

+

+ O aplicativo oferece recursos de exportação e importação de configurações em formato JSON. + Essa funcionalidade é totalmente local: o arquivo é gerado e baixado no seu dispositivo, + e a importação ocorre a partir de um arquivo que você seleciona manualmente. + Nenhum dado é enviado a servidores externos durante esse processo. +

+
+ +
+

4. Conteúdo patrocinado

+

+ Em certas categorias, o aplicativo pode exibir cards identificados como "Patrocinado". + Esses cards são injetados durante a renderização da página e não fazem parte dos seus dados: + eles não são salvos nem exportados junto com suas configurações. +

+

+ A seleção do anúncio é feita exclusivamente no seu navegador, com + base no nome da categoria ativa. Nenhum dado pessoal (nome, atalhos, histórico) é enviado ou usado + para escolher o anúncio a exibir. +

+

+ Ao clicar em um card patrocinado, você será direcionado ao site do anunciante. Uma vez no site do + anunciante, aplicam-se a Política de Privacidade e os Termos de Serviço desse site. +

+
+ +
+

5. Serviços de terceiros

+

+ O aplicativo consulta a API Open-Meteo (gratuita e sem chave de API) + para exibir informações de clima, caso você tenha configurado uma cidade. Essa consulta envia apenas + o nome da cidade digitada, sem qualquer identificador pessoal. +

+

+ As bibliotecas externas carregadas (Tailwind CSS, Lucide Icons, Google Fonts) servem + apenas para funcionamento técnico e não coletam dados de uso do aplicativo. +

+
+ +
+

6. Cookies e rastreamento

+

+ O aplicativo não utiliza cookies de publicidade comportamental, redes de anúncios ou ferramentas de + analytics próprias. Os cards patrocinados são selecionados localmente pelo nome da categoria ativa + (ver item 4). +

+
+ +
+

7. Compartilhamento de dados

+

+ Não vendemos, alugamos ou compartilhamos suas informações pessoais. O aplicativo não possui servidor + próprio e não transmite seus dados de configuração para nenhum serviço externo. +

+
+ +
+

8. Segurança

+

+ Todo o tráfego ocorre sobre HTTPS. Seus dados são armazenados apenas no localStorage + do seu navegador, que é isolado por origem e inacessível a outros sites. URLs inseridas pelo usuário + são sanitizadas para bloquear esquemas potencialmente perigosos (como javascript:). +

+
+ +
+

9. Seus direitos (LGPD)

+

+ Nos termos da Lei Geral de Proteção de Dados (Lei nº 13.709/2018), você pode solicitar acesso, + correção ou exclusão dos dados associados ao seu uso. Como seus dados residem exclusivamente no seu + próprio dispositivo, você pode exercê-los diretamente pela limpeza de dados do navegador, + ou entrando em contato conosco. +

+
+ +
+

10. Alterações nesta política

+

+ Esta política pode ser atualizada para refletir mudanças no funcionamento do aplicativo ou em + requisitos legais. A data da última revisão consta no início desta página. +

+
+ +
+

11. Contato

+

+ Dúvidas sobre esta Política de Privacidade podem ser enviadas para: + weltonmouradev@gmail.com. +

+
+
+ + +
+ + diff --git a/web/robots.txt b/web/robots.txt new file mode 100644 index 0000000..a669f89 --- /dev/null +++ b/web/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Allow: / +Disallow: /initpage/sw.js + +Sitemap: https://weltonmoura.com.br/sitemap.xml diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..89e3ee6 --- /dev/null +++ b/web/styles.css @@ -0,0 +1,265 @@ +:root { + --accent-color: #3b82f6; + --accent-hover: #2563eb; + --modal-panel-bg: linear-gradient(180deg, rgba(30, 27, 75, 0.94), rgba(15, 18, 38, 0.96)); + --modal-panel-border: rgba(139, 124, 219, 0.18); +} + +/* Modal: fundo coerente com glassmorphism do projeto */ +.modal-panel { + background: rgba(15, 23, 42, 0.92); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.14); + box-shadow: 0 40px 90px -20px rgba(2, 6, 23, 0.95); +} + +/* Limitar texto em até 2 linhas */ +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Limitar texto em até 3 linhas */ +.line-clamp-3 { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Scrollbars elegantes */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 9999px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.4); +} +.no-scrollbar::-webkit-scrollbar { + display: none; +} +.no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; +} + +/* Estilos Glassmorphism */ +.glass-panel { + background: rgba(15, 23, 42, 0.78); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.glass-card { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease, background 0.2s ease; +} + +.glass-card:hover { + border-color: rgba(255, 255, 255, 0.28); + transform: translateY(-3px); + box-shadow: 0 14px 28px -8px rgba(0, 0, 0, 0.5); +} + +.glass-translucent { + background: rgba(15, 23, 42, 0.38) !important; + backdrop-filter: blur(16px) !important; + -webkit-backdrop-filter: blur(16px) !important; + border: 1px solid rgba(255, 255, 255, 0.14) !important; +} + +/* Botão de sidebar */ +.sidebar-btn { + width: 36px; + height: 36px; + border-radius: 12px; + display: flex; + align-items: center; + color: rgba(226, 232, 255, 0.9); + transition: all 0.2s ease; + flex-shrink: 0; + background: transparent; +} + +.sidebar-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: white; + transform: scale(1.05); +} + +.sidebar-btn:active { + transform: scale(0.95); +} + +/* Sidebar/barra de navegação */ +#app-sidebar { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 20px 60px -20px rgba(2, 6, 23, 0.9); +} +/* Checklist item */ +.check-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; +} + +.check-item input[type="checkbox"] { + accent-color: var(--accent-color); + width: 14px; + height: 14px; + cursor: pointer; + flex-shrink: 0; +} + +.check-item.completed .check-item-text { + text-decoration: line-through; + color: rgba(148, 163, 184, 0.8); +} + +.check-item input.check-item-text-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: white; + font-size: 11px; + padding: 0 4px; +} + +/* Linha do calendário com ponto de tarefa */ +.cal-day-cell { + position: relative; + padding: 5px 4px; +} + +.cal-task-dot { + position: absolute; + bottom: 2px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--accent-color); + pointer-events: none; +} + +/* Botão de layout selecionado */ +.layout-btn.selected { + background: var(--accent-color) !important; + border-color: transparent !important; + color: #ffffff; + font-weight: 700; +} + +/* Aba ativa */ +.tab-btn.selected { + background: var(--accent-color) !important; + color: #ffffff; + font-weight: 700; + box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.4); +} + +/* Ícone do atalho preenche toda a caixa (desktop e mobile), sem bordas */ +.shortcut-icon-box { + padding: 0 !important; + overflow: hidden !important; + border: none !important; +} +.shortcut-icon-box img { + object-fit: cover !important; +} + +/* Estilos do Drag and Drop */ +.card-dragging { + opacity: 0.35 !important; + transform: scale(0.95) !important; + border: 2px dashed var(--accent-color) !important; +} +.card-drag-over { + border: 2px solid var(--accent-color) !important; + transform: scale(1.02) !important; + box-shadow: 0 0 15px rgba(59, 130, 246, 0.4); +} +.tab-drag-over { + background: rgba(255, 255, 255, 0.25) !important; + border-color: var(--accent-color) !important; + transform: scale(1.05); +} + +/* Transição suave do contêiner */ +#shortcuts-container { + transition: opacity 0.18s ease, transform 0.18s ease; +} +#shortcuts-container.switching { + opacity: 0; + transform: scale(0.985); +} + +/* Otimizações Mobile */ +@media (max-width: 768px) { + .glass-panel { + background: rgba(15, 23, 42, 0.95) !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + } + .glass-card, .glass-translucent { + background: rgba(30, 41, 59, 0.92) !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + transform: none !important; + } + .glass-card:hover { + transform: none !important; + } + .animate-slide-up, .animate-fade-in { + animation: none !important; + opacity: 1 !important; + transform: none !important; + } + + #app-sidebar { + height: 48px; + opacity: 0.95; + transition: none; + overflow: visible; + flex-direction: row; + align-items: center; + left: 0; + right: 0; + border-left: 0; + border-right: 0; + } + .sidebar-btn { + width: 32px; + height: 32px; + } + .card-actions button { + padding: 8px; + } + .card-actions button i { + width: 16px !important; + height: 16px !important; + } +} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..fbe01dd --- /dev/null +++ b/web/sw.js @@ -0,0 +1,41 @@ +const CACHE = "initpage-v4"; +const ASSETS = [ + "./", + "./index.html", + "./styles.css", + "./app.js", + "./ads.js", + "./manifest.json", + "./icon.svg", + "./robots.txt" +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(ASSETS)).catch(() => {}) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + if (event.request.method !== "GET") return; + event.respondWith( + caches.match(event.request).then((cached) => { + const fetchPromise = fetch(event.request).then((response) => { + if (response && response.status === 200 && response.type === "basic") { + const clone = response.clone(); + caches.open(CACHE).then((cache) => cache.put(event.request, clone)); + } + return response; + }).catch(() => cached); + return cached || fetchPromise; + }) + ); +}); diff --git a/web/termos-servico/index.html b/web/termos-servico/index.html new file mode 100644 index 0000000..9497142 --- /dev/null +++ b/web/termos-servico/index.html @@ -0,0 +1,162 @@ + + + + + + Termos de Serviço · InitPage + + + + + + + + +
+ +
+

+ InitPage · Termos de Serviço +

+ weltonmoura.com.br +
+ +
+
+
+

Última atualização: 18 de outubro de 2026

+

+ Estes Termos de Serviço regem o uso do aplicativo web InitPage + (doravante "o aplicativo"). Ao utilizar o aplicativo, você declara ter lido, entendido e aceitado + estes termos. +

+
+ +
+

1. Descrição do serviço

+

+ O aplicativo fornece uma página inicial personalizável do navegador, permitindo organizar atalhos, + categorias, pesquisa e preferências visuais. Todo o processamento acontece no seu dispositivo + (cliente), sem infraestrutura de servidor própria. +

+
+ +
+

2. Aceitação dos termos

+

+ O acesso e o uso do aplicativo implicam na aceitação integral destes Termos. Se você não concorda, + não utilize o aplicativo. +

+
+ +
+

3. Uso permitido

+

+ Você concorda em utilizar o aplicativo apenas para fins legais e lícitos, sem tentar interferir no + seu funcionamento, sobrecarregar os serviços, ou burlar qualquer medida de segurança. +

+
+ +
+

4. Backup e exportação de dados

+

+ O aplicativo permite exportar suas configurações em formato JSON para backup local. A + responsabilidade por manter cópias de segurança é inteiramente sua. O arquivo exportado + contém suas configurações e deve ser tratado como informação sua. +

+
+ +
+

5. Responsabilidades do usuário

+
    +
  • Ser o único responsável pelas informações salvas localmente no seu navegador;
  • +
  • Manter cópias de segurança (exportação/backup) se desejar preservar suas configurações;
  • +
  • Não utilizar o aplicativo para armazenar ou divulgar conteúdos ilegais.
  • +
+
+ +
+

6. Conteúdo patrocinado

+

+ O aplicativo pode exibir cards identificados como "Patrocinado" em + determinadas categorias. Esses cards são sempre claramente sinalizados e não fazem parte das suas + configurações: não são armazenados nem exportados. +

+

+ Os anúncios são selecionados no seu próprio navegador, conforme o nome da categoria ativa, e não + envolvem o envio dos seus dados. Ao clicar em um card patrocinado, você será direcionado ao site do + anunciante, e a responsabilidade pelo conteúdo do anúncio e do destino do link é do anunciante. +

+
+ +
+

7. Propriedade intelectual

+

+ O conteúdo que você cria no aplicativo (atalhos, categorias, preferências) pertence a você. O + aplicativo em si, incluindo seu código-fonte, design e identidade visual, é de sua exclusiva + propriedade enquanto mantenedor, salvo indicação em contrário. +

+
+ +
+

8. Disponibilidade e garantias

+

+ O aplicativo é fornecido gratuitamente e "no estado em que se encontra" ("as is"). Não garantimos + disponibilidade ininterrupta ou ausência de erros. O funcionamento depende do navegador e de + serviços de terceiros que não controlamos (bibliotecas externas, API de clima). +

+
+ +
+

9. Limitação de responsabilidade

+

+ Na máxima extensão permitida por lei, não nos responsabilizamos por perdas diretas ou indiretas + decorrentes do uso, impossibilidade de uso, ou perda de dados associada ao aplicativo. É + recomendável manter backups frequentes das suas configurações. +

+
+ +
+

10. Alterações dos termos

+

+ Podemos atualizar estes Termos periodicamente. A versão vigente será sempre a publicada nesta + página, com a data de última atualização indicada no topo. +

+
+ +
+

11. Lei aplicável

+

+ Estes Termos são regidos pelas leis da República Federativa do Brasil. Eventuais controvérsias + serão resolvidas no foro da comarca do mantenedor do aplicativo, respeitadas as disposições legais + aplicáveis. +

+
+ +
+

12. Contato

+

+ Dúvidas sobre estes Termos podem ser enviadas para: + weltonmouradev@gmail.com. +

+
+
+ + +
+ +