feat: initpage com sync Go + auth + frontend em web/

This commit is contained in:
2026-10-21 18:06:25 -03:00
commit 17b81a808a
24 changed files with 4807 additions and 0 deletions

7
.env.example Normal file
View File

@@ -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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/server
/data/*.db
/data/*.db-*
*.log
.env

17
Dockerfile Normal file
View File

@@ -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"]

97
cmd/server/main.go Normal file
View File

@@ -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))
}

12
docker-compose.yml Normal file
View File

@@ -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

28
go.mod Normal file
View File

@@ -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
)

59
go.sum Normal file
View File

@@ -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=

65
internal/auth/password.go Normal file
View File

@@ -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
}

34
internal/config/config.go Normal file
View File

@@ -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
}

41
internal/db/db.go Normal file
View File

@@ -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
);
`

121
internal/handlers/auth.go Normal file
View File

@@ -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)
}

View File

@@ -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 ""
}

View File

@@ -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)
})
}
}

91
internal/sync/sync.go Normal file
View File

@@ -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:])
}

153
web/ads.js Normal file
View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
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 '<div class="w-full h-full bg-gradient-to-tr from-slate-800 via-indigo-950 to-slate-800 flex items-center justify-center">' +
'<svg class="w-8 h-8 text-white/25" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.4-9A4.5 4.5 0 1112.5 8H10a5 5 0 00-5 5"/></svg>' +
"</div>";
}
return `<img src="${escapeHtml(cover)}" alt="${escapeHtml(ad.name)}" loading="lazy" class="w-full h-full object-cover" onerror="this.onerror=null;this.style.display='none';">`;
}
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 = `
<div class="w-11 h-11 rounded-2xl glass-panel p-2 flex items-center justify-center shadow-md relative">
<span class="absolute -top-1.5 -right-1.5 text-[8px] font-extrabold tracking-tighter bg-amber-400 text-slate-900 rounded-full w-4 h-4 flex items-center justify-center">AD</span>
<img src="${icon}" alt="" class="w-full h-full object-contain" onerror="this.onerror=null; this.src='https://www.google.com/s2/favicons?domain=example.com';">
</div>
<span class="text-[10px] font-semibold text-slate-200 mt-1.5 truncate w-full text-center">${name}</span>
`;
} else if (layout === "list") {
card.classList.add("flex", "items-center", "p-3", "gap-4");
card.innerHTML = `
<div class="w-14 h-14 rounded-xl overflow-hidden relative flex-shrink-0 bg-slate-900 border border-white/10">
${getCoverHtml(ad)}
<img src="${icon}" class="w-5 h-5 absolute bottom-1.5 right-1.5 glass-panel p-0.5 rounded shadow" onerror="this.onerror=null; this.src='https://www.google.com/s2/favicons?domain=example.com';">
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h4 class="text-sm font-bold text-white truncate">${name}</h4>
<span class="px-2 py-0.5 rounded-full bg-amber-400/90 text-[10px] font-bold text-slate-900 flex-shrink-0">Patrocinado</span>
</div>
<p class="text-xs text-slate-400 line-clamp-3">${desc}</p>
</div>
`;
} else {
const bannerHeight = cardSize === "sm"
? "h-16 sm:h-20"
: cardSize === "lg"
? "h-32 sm:h-36"
: "h-24 sm:h-28";
card.innerHTML = `
<div class="w-full ${bannerHeight} relative overflow-hidden bg-slate-900">
${getCoverHtml(ad)}
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-black/20"></div>
<div class="absolute top-2 left-2 px-2 py-0.5 rounded-md bg-amber-400/95 text-slate-900 text-[10px] font-bold shadow">Patrocinado</div>
<div class="shortcut-icon-box absolute bottom-2 left-3 w-8 h-8 rounded-xl glass-panel p-1.5 flex items-center justify-center shadow-lg">
<img src="${icon}" alt="" class="w-full h-full object-contain" onerror="this.onerror=null; this.src='https://www.google.com/s2/favicons?domain=example.com';">
</div>
</div>
<div class="p-3 pt-2">
<h4 class="text-xs sm:text-sm font-bold text-white truncate group-hover:text-accent transition-colors">${name}</h4>
<p class="text-[10px] sm:text-xs text-slate-400 line-clamp-3 mt-0.5">${desc}</p>
</div>
`;
}
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) || {});
});
})();

2392
web/app.js Normal file

File diff suppressed because it is too large Load Diff

16
web/icon.svg Normal file
View File

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0f172a"/>
<rect width="512" height="512" rx="96" fill="url(#g)"/>
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#2563eb"/>
<stop offset="1" stop-color="#7c3aed"/>
</linearGradient>
</defs>
<g fill="none" stroke="#ffffff" stroke-width="28" stroke-linecap="round">
<rect x="128" y="128" width="112" height="112" rx="24"/>
<rect x="272" y="128" width="112" height="112" rx="24"/>
<rect x="128" y="272" width="112" height="112" rx="24"/>
<rect x="272" y="272" width="112" height="112" rx="24"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 702 B

899
web/index.html Normal file
View File

@@ -0,0 +1,899 @@
<!DOCTYPE html>
<html lang="pt-BR" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/initpage/">
<title>InitPage — Seu navegador, personalizado</title>
<meta name="description" content="Pagina inicial personalizavel com atalhos, categorias, busca e plano de fundo. Tudo salvo localmente no seu navegador.">
<meta name="theme-color" content="#0f172a">
<link rel="icon" href="https://www.google.com/s2/favicons?domain=weltonmoura.com.br&sz=128">
<link rel="manifest" href="manifest.json">
<!-- Open Graph / Social -->
<meta property="og:type" content="website">
<meta property="og:title" content="InitPage — Seu navegador, personalizado">
<meta property="og:description" content="Pagina inicial personalizavel com atalhos, categorias, busca e plano de fundo.">
<meta property="og:url" content="https://apps.weltonmoura.com.br/initpage/">
<meta property="og:site_name" content="InitPage">
<meta property="og:image" content="https://apps.weltonmoura.com.br/initpage/icon.svg">
<meta name="twitter:card" content="summary">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Ícones Lucide -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Anúncios patrocinados (injeção independente, fora do estado/export) -->
<script src="ads.js" defer></script>
<!-- Fonte Google Plus Jakarta Sans -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['"Plus Jakarta Sans"', 'sans-serif'],
},
colors: {
accent: 'var(--accent-color)',
'accent-hover': 'var(--accent-hover)',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out forwards',
'slide-up': 'slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(12px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
}
}
}
}
}
</script>
<link rel="stylesheet" href="styles.css">
</head>
<body class="h-full bg-slate-950 text-slate-100 font-sans antialiased overflow-x-hidden relative flex flex-col justify-between pb-14 sm:pb-0 sm:pr-12 sm:pl-0">
<!-- Fundo com Imagem e Máscara -->
<div id="bg-cover" class="fixed inset-0 bg-cover bg-center transition-all duration-700 -z-20 transform scale-105"></div>
<div id="bg-overlay" class="fixed inset-0 bg-black/60 transition-all duration-500 -z-10"></div>
<!-- Notificação Toast Flutuante -->
<div id="toast" role="status" aria-live="polite" class="fixed bottom-6 right-6 z-[90] transform translate-y-16 opacity-0 transition-all duration-300 pointer-events-none flex items-center gap-3 px-4 py-3 rounded-2xl glass-panel text-sm text-white shadow-2xl">
<i id="toast-icon" data-lucide="info" class="w-4 h-4 text-accent"></i>
<span id="toast-msg">Notificação</span>
</div>
<!-- Sidebar Lateral Direita (desktop) e Barra Inferior (mobile) -->
<aside id="app-sidebar" class="fixed right-0 top-auto bottom-0 w-full h-12 z-600 flex flex-row items-center justify-center px-2 gap-2 border-t border-white/10 sm:top-0 sm:bottom-0 sm:right-0 sm:left-auto sm:w-12 sm:h-auto sm:flex-col sm:justify-start sm:pt-4 sm:pb-4 sm:border-t-0 sm:border-l sm:border-white/10 sm:border-r-0">
<button id="clock-calendar-trigger" title="Calendário" aria-label="Abrir Calendário" class="sidebar-btn relative group">
<i data-lucide="calendar" class="w-6 h-6"></i>
</button>
<button id="btn-open-tasks" title="Tarefas" aria-label="Abrir Tarefas" class="sidebar-btn relative group">
<i data-lucide="clipboard-list" class="w-6 h-6"></i>
<span id="tasks-dot-badge" class="hidden absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
</button>
<button id="btn-open-notes" title="Notas" aria-label="Abrir Notas" class="sidebar-btn relative group" style="color: var(--accent-color);">
<i data-lucide="sticky-note" class="w-6 h-6"></i>
</button>
<div class="hidden sm:block flex-1"></div>
<button id="btn-open-settings" title="Configurações e Customização" aria-label="Abrir Configurações" class="sidebar-btn relative group">
<i data-lucide="settings" class="w-6 h-6 group-hover:rotate-45 transition-transform duration-300"></i>
</button>
</aside>
<!-- Barra Superior / Cabeçalho Minimalista -->
<header class="w-full px-5 sm:px-8 py-4 flex items-center justify-end z-30 animate-fade-in">
</header>
<!-- Área Central de Conteúdo -->
<main class="w-full max-w-6xl mx-auto px-4 sm:px-8 pt-4 sm:pt-3 pb-12 sm:pb-12 flex-1 flex flex-col items-center justify-start gap-5 sm:gap-7 z-10">
<!-- 1. BLOCO INTEGRADO: RELÓGIO + DATA + SAUDAÇÃO (Gatilho do Calendário) -->
<section class="w-full flex flex-col items-center justify-center animate-slide-up">
<button id="clock-calendar-trigger" type="button" class="group flex flex-col items-center justify-center p-3 sm:p-4 rounded-3xl hover:bg-white/5 transition-all duration-300 active:scale-[0.99] cursor-pointer outline-none select-none" title="Clique para expandir o calendário">
<h1 id="clock-display" class="text-6xl sm:text-7xl md:text-8xl font-black tracking-tight text-white drop-shadow-md group-hover:scale-[1.02] transition-transform duration-300">
00:00
</h1>
<!-- Elemento de Data e Saudação Integrados sem Borda e sem Animação -->
<div class="flex flex-wrap items-center justify-center gap-2 mt-2 text-sm sm:text-base font-medium text-slate-300">
<span class="text-xs sm:text-sm text-slate-200 font-medium flex items-center gap-1.5">
<span id="header-date">Carregando data...</span>
<i data-lucide="calendar" class="w-3.5 h-3.5 text-accent opacity-80 ml-0.5"></i>
</span>
<span class="text-slate-400 hidden sm:inline">•</span>
<div class="text-slate-200 font-light drop-shadow">
<span id="greeting-text">Olá</span>,
<span id="user-display-name" class="font-bold text-white tracking-wide underline decoration-accent decoration-2 underline-offset-4 hover:opacity-85 transition" title="Alterar nome">Visitante</span>!
</div>
</div>
</button>
<!-- Informação de Tempo (meteorológico) -->
<div id="weather-info" class="hidden mt-1">
<span class="inline-flex items-center gap-1.5 text-xs sm:text-sm text-slate-300 font-medium glass-card px-3 py-1 rounded-full">
<span id="weather-icon">☀️</span>
<span id="weather-temp">--°</span>
<span class="text-slate-500">·</span>
<span id="weather-city" class="capitalize">Cidade</span>
</span>
</div>
<!-- ==================== COMPONENTE DE CALENDÁRIO INTEGRADO À PÁGINA ==================== -->
<div id="calendar-widget" class="hidden mt-3 w-[92vw] sm:w-[26rem] md:w-[28rem] rounded-3xl glass-translucent p-4 border border-white/15 animate-fade-in">
<!-- Topo do Widget de Calendário -->
<div class="flex items-center justify-between pb-3 border-b border-white/10 mb-3">
<button type="button" id="cal-prev-month" aria-label="Mês anterior" class="p-1.5 rounded-xl hover:bg-white/15 text-slate-300 hover:text-white transition active:scale-95">
<i data-lucide="chevron-left" class="w-4 h-4"></i>
</button>
<div class="text-center">
<h4 id="cal-month-year" class="text-xs sm:text-sm font-bold text-white capitalize tracking-wide">Mês Ano</h4>
</div>
<button type="button" id="cal-next-month" aria-label="Próximo mês" class="p-1.5 rounded-xl hover:bg-white/15 text-slate-300 hover:text-white transition active:scale-95">
<i data-lucide="chevron-right" class="w-4 h-4"></i>
</button>
</div>
<!-- Cabeçalho dos Dias da Semana -->
<div class="grid grid-cols-7 text-center text-[10px] font-bold text-slate-400 mb-2">
<span>DOM</span>
<span>SEG</span>
<span>TER</span>
<span>QUA</span>
<span>QUI</span>
<span>SEX</span>
<span>SÁB</span>
</div>
<!-- Grade de Dias do Mês -->
<div id="cal-days-grid" class="grid grid-cols-7 gap-1 text-center text-xs">
<!-- Dias renderizados dinamicamente via JS -->
</div>
<!-- Tarefas do Dia Selecionado -->
<div id="cal-day-tasks" class="hidden mt-3 pt-2.5 border-t border-white/10 space-y-1.5 max-h-44 overflow-y-auto pr-1">
<div class="flex items-center justify-between mb-1">
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Tarefas do dia</p>
<button id="btn-add-task-from-day" type="button" class="px-1.5 py-0.5 rounded-md bg-white/10 hover:bg-white/20 text-[9px] text-slate-300 flex items-center gap-1" title="Adicionar tarefa neste dia" aria-label="Adicionar tarefa neste dia">
<i data-lucide="plus" class="w-2.5 h-2.5"></i> Adicionar
</button>
</div>
<div id="cal-day-tasks-list" class="space-y-1.5">
<!-- Renderizado dinamicamente via JS -->
</div>
</div>
<!-- Rodapé do Widget -->
<div class="mt-3 pt-2.5 border-t border-white/10 flex items-center justify-between text-[11px] text-slate-400">
<span id="cal-selected-info" aria-live="polite">Hoje</span>
<button type="button" id="cal-btn-today" class="text-accent hover:underline font-semibold cursor-pointer">Ir para Hoje</button>
</div>
</div>
</section>
<!-- 2. BARRA DE PESQUISA COM O MESMO FUNDO DOS CARDS (glass-card) -->
<section class="w-full max-w-2xl relative z-40 animate-slide-up">
<form id="search-form" class="relative flex items-center glass-card rounded-2xl p-1.5 shadow-2xl focus-within:ring-2 focus-within:ring-accent focus-within:border-accent transition-all duration-300">
<!-- Dropdown Seletor de Buscador -->
<div class="relative">
<button type="button" id="search-engine-btn" class="flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-3.5 py-2 rounded-xl hover:bg-white/10 text-xs font-semibold text-slate-100 transition active:scale-95">
<span id="search-engine-icon" class="text-base">🌐</span>
<span id="search-engine-label" class="hidden sm:inline">Google</span>
<i data-lucide="chevron-down" class="w-3.5 h-3.5 opacity-70"></i>
</button>
<div id="search-engine-menu" class="hidden absolute top-full left-0 mt-2 w-48 rounded-2xl glass-panel py-1.5 shadow-2xl border border-white/20 z-50 overflow-hidden">
<button type="button" data-engine="google" class="engine-opt w-full px-3.5 py-2.5 text-left text-xs text-slate-200 hover:bg-white/15 flex items-center gap-2.5 transition">
<span class="text-base">🌐</span> Google
</button>
<button type="button" data-engine="duckduckgo" class="engine-opt w-full px-3.5 py-2.5 text-left text-xs text-slate-200 hover:bg-white/15 flex items-center gap-2.5 transition">
<span class="text-base">🦆</span> DuckDuckGo
</button>
<button type="button" data-engine="bing" class="engine-opt w-full px-3.5 py-2.5 text-left text-xs text-slate-200 hover:bg-white/15 flex items-center gap-2.5 transition">
<span class="text-base">🔎</span> Bing
</button>
<button type="button" data-engine="brave" class="engine-opt w-full px-3.5 py-2.5 text-left text-xs text-slate-200 hover:bg-white/15 flex items-center gap-2.5 transition">
<span class="text-base">🦁</span> Brave
</button>
<button type="button" data-engine="youtube" class="engine-opt w-full px-3.5 py-2.5 text-left text-xs text-slate-200 hover:bg-white/15 flex items-center gap-2.5 transition">
<span class="text-base">▶️</span> YouTube
</button>
<button type="button" data-engine="github" class="engine-opt w-full px-3.5 py-2.5 text-left text-xs text-slate-200 hover:bg-white/15 flex items-center gap-2.5 transition">
<span class="text-base">🐙</span> GitHub
</button>
</div>
</div>
<input
type="text"
id="search-input"
placeholder="Pesquisar ou digitar URL..."
autocomplete="off"
class="flex-1 bg-transparent px-3 py-2 text-sm sm:text-base text-white placeholder-slate-400 focus:outline-none min-w-0"
>
<button type="submit" title="Buscar em nova aba" aria-label="Buscar em nova aba" class="p-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white transition shadow-md flex items-center justify-center flex-shrink-0 active:scale-95">
<i data-lucide="arrow-right" class="w-4 h-4"></i>
</button>
</form>
</section>
<!-- 3. SEÇÃO DE ABAS DE CATEGORIAS E ATALHOS -->
<section class="w-full relative z-10 space-y-3.5">
<!-- Linha de Abas com o Botão '+' ao final -->
<div class="flex items-center justify-between gap-3 border-b border-white/10 pb-2">
<div id="tabs-wrapper" class="flex items-center gap-2 overflow-x-auto no-scrollbar py-1 flex-1 pr-2">
<!-- Abas renderizadas dinamicamente + botão '+' de gerenciar categorias -->
</div>
<div class="flex items-center gap-2 flex-shrink-0 text-xs text-slate-400 pl-2 border-l border-white/10">
<span id="shortcuts-count" class="font-medium whitespace-nowrap">0 atalhos</span>
</div>
</div>
<!-- Grade / Lista / Dock de Atalhos (com o Card '+' ao final) -->
<div id="shortcuts-container" class="min-h-[140px] w-full">
<!-- Renderizado dinamicamente via JavaScript -->
</div>
</section>
</main>
<!-- Rodapé -->
<footer class="w-full px-6 py-3 pb-16 sm:pb-3 flex flex-col items-center gap-2 text-xs text-slate-400/80 z-10">
<div class="w-full flex flex-col sm:flex-row items-center justify-between gap-2">
<div class="flex items-center gap-2">
</div>
<div class="flex items-center gap-2">
</div>
</div>
<div class="flex items-center gap-3 text-[11px] text-slate-500">
<a href="https://weltonmoura.com.br/initpage/politica-privacidade/" class="hover:text-white transition">Política de Privacidade</a>
<span class="text-slate-700">•</span>
<a href="https://weltonmoura.com.br/initpage/termos-servico/" class="hover:text-white transition">Termos de Serviço</a>
<span class="text-slate-700">•</span>
<span>by <a href="https://weltonmoura.com.br/" class="hover:text-amber-400 text-white transition">Welton Moura</a></span>
</div>
</footer>
<!-- ==================== MODAL DE ADICIONAR / EDITAR ATALHO (Z-50) ==================== -->
<div id="modal-shortcut" role="dialog" aria-modal="true" class="fixed inset-0 z-50 hidden flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-fade-in">
<div class="modal-panel w-full max-w-md rounded-3xl p-6 shadow-2xl relative max-h-[92vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 id="modal-shortcut-title" class="text-lg font-bold text-white flex items-center gap-2">
<i data-lucide="bookmark-plus" class="w-5 h-5 text-accent"></i>
Novo Atalho
</h3>
<button id="btn-close-shortcut-modal" aria-label="Fechar" class="p-1.5 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form id="form-shortcut" class="space-y-3.5 text-xs">
<input type="hidden" id="input-shortcut-id">
<div>
<label class="block text-slate-300 font-medium mb-1">Nome do Atalho *</label>
<input type="text" id="input-shortcut-name" required placeholder="Ex: GitHub, YouTube, Notícias" class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
</div>
<div>
<label class="block text-slate-300 font-medium mb-1">Endereço Web (URL) *</label>
<input type="text" id="input-shortcut-url" required placeholder="exemplo.com ou https://exemplo.com" class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
</div>
<div>
<label class="block text-slate-300 font-medium mb-1">Categoria *</label>
<div class="flex gap-2">
<select id="input-shortcut-category" class="flex-1 bg-slate-900/80 border border-white/15 rounded-xl px-3 py-2.5 text-xs text-white focus:outline-none focus:border-accent">
<!-- Categorias inseridas dinamicamente -->
</select>
<button type="button" id="btn-shortcut-new-cat" class="px-3 py-2 rounded-xl bg-white/10 hover:bg-white/20 text-white font-medium text-xs transition flex items-center gap-1" title="Gerenciar Categorias">
<i data-lucide="settings-2" class="w-3 h-3 text-accent"></i> Gerenciar
</button>
</div>
</div>
<div>
<label class="block text-slate-300 font-medium mb-1">Imagem de Capa (Edge-to-Edge)</label>
<input type="url" id="input-shortcut-cover" placeholder="URL da imagem (ou deixe vazio para gradiente elegante)" class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
</div>
<div>
<label class="block text-slate-300 font-medium mb-1">Descrição (opcional)</label>
<input type="text" id="input-shortcut-desc" placeholder="Ex: Comunidade de programadores" class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
</div>
<div>
<label class="block text-slate-300 font-medium mb-1">Ícone Personalizado (opcional)</label>
<input type="url" id="input-shortcut-icon" placeholder="Deixe vazio para obter o favicon oficial" class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
</div>
<div class="flex items-center justify-end gap-2.5 pt-4 border-t border-white/10">
<button type="button" id="btn-cancel-shortcut" class="px-4 py-2 rounded-xl text-slate-300 hover:bg-white/10 transition">Cancelar</button>
<button type="submit" class="px-5 py-2 rounded-xl bg-accent hover:bg-accent-hover text-white font-semibold transition shadow-lg flex items-center gap-2">
<i data-lucide="check" class="w-4 h-4"></i> Salvar Atalho
</button>
</div>
</form>
</div>
</div>
<!-- ==================== MODAL DE GERENCIAR CATEGORIAS (CRUD) (Z-[60]) ==================== -->
<div id="modal-categories" role="dialog" aria-modal="true" class="fixed inset-0 z-[60] hidden flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
<div class="modal-panel w-full max-w-md rounded-3xl p-6 shadow-2xl relative space-y-4">
<div class="flex items-center justify-between border-b border-white/10 pb-3">
<h3 class="text-base font-bold text-white flex items-center gap-2">
<i data-lucide="tags" class="w-5 h-5 text-accent"></i>
Gerenciar Categorias
</h3>
<button id="btn-close-categories-modal" aria-label="Fechar" class="p-1.5 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<!-- Formulário de Criação/Edição de Categoria -->
<form id="form-category" class="space-y-3">
<input type="hidden" id="input-cat-id">
<div class="flex gap-2">
<input type="text" id="input-cat-name" required placeholder="Nome da categoria..." class="flex-1 bg-slate-900/90 border border-white/20 rounded-xl px-3.5 py-2 text-xs text-white focus:outline-none focus:border-accent">
<button type="submit" id="btn-submit-cat" class="px-4 py-2 rounded-xl bg-accent hover:bg-accent-hover text-white text-xs font-semibold transition shadow flex items-center gap-1.5">
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
<span id="btn-submit-cat-text">Adicionar</span>
</button>
<button type="button" id="btn-cancel-cat-edit" class="hidden px-3 py-2 rounded-xl bg-white/10 hover:bg-white/20 text-slate-300 text-xs transition">
Cancelar
</button>
</div>
</form>
<!-- Lista de Categorias com Ações -->
<div class="space-y-1.5 max-h-60 overflow-y-auto pr-1" id="categories-crud-list">
<!-- Itens de categoria gerados dinamicamente -->
</div>
<div class="pt-3 border-t border-white/10 flex justify-between items-center text-xs text-slate-400">
<span>Arraste as abas na tela para mudar a ordem.</span>
<button type="button" id="btn-done-categories" class="px-4 py-1.5 rounded-xl bg-white/10 hover:bg-white/20 text-white font-medium transition">
Concluir
</button>
</div>
</div>
</div>
<!-- ==================== MODAL DE CONFIRMAÇÃO DE EXCLUSÃO DE CATEGORIA (Z-[70]) ==================== -->
<div id="modal-delete-cat-confirm" role="dialog" aria-modal="true" class="fixed inset-0 z-[70] hidden flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-fade-in">
<div class="modal-panel w-full max-w-sm rounded-3xl p-6 shadow-2xl border border-rose-500/30 text-center space-y-4">
<div class="w-12 h-12 rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center mx-auto">
<i data-lucide="folder-x" class="w-6 h-6"></i>
</div>
<div>
<h3 class="text-base font-bold text-white">Excluir categoria?</h3>
<p id="delete-cat-confirm-text" class="text-xs text-slate-300 mt-1">Os atalhos associados a ela serão movidos para Geral.</p>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button type="button" id="btn-cancel-cat-delete" class="px-4 py-2 rounded-xl text-slate-300 hover:bg-white/10 transition text-xs font-semibold">
Cancelar
</button>
<button type="button" id="btn-confirm-cat-delete" class="px-5 py-2 rounded-xl bg-rose-600 hover:bg-rose-500 text-white font-semibold transition text-xs shadow-lg">
Excluir Categoria
</button>
</div>
</div>
</div>
<!-- ==================== MODAL DE CONFIRMAÇÃO DE EXCLUSÃO DE ATALHO ==================== -->
<div id="modal-delete-confirm" role="dialog" aria-modal="true" class="fixed inset-0 z-50 hidden flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-fade-in">
<div class="modal-panel w-full max-w-sm rounded-3xl p-6 shadow-2xl border border-rose-500/20 text-center space-y-4">
<div class="w-12 h-12 rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center mx-auto">
<i data-lucide="alert-triangle" class="w-6 h-6"></i>
</div>
<div>
<h3 class="text-base font-bold text-white">Excluir este atalho?</h3>
<p id="delete-confirm-text" class="text-xs text-slate-300 mt-1">Essa ação não pode ser desfeita.</p>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button type="button" id="btn-cancel-delete" class="px-4 py-2 rounded-xl text-slate-300 hover:bg-white/10 transition text-xs font-semibold">
Cancelar
</button>
<button type="button" id="btn-confirm-delete" class="px-5 py-2 rounded-xl bg-rose-600 hover:bg-rose-500 text-white font-semibold transition text-xs shadow-lg">
Sim, Excluir
</button>
</div>
</div>
</div>
<!-- ==================== MODAL DE COLAR JSON ==================== -->
<div id="modal-paste-json" role="dialog" aria-modal="true" class="fixed inset-0 z-[70] hidden flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-fade-in">
<div class="modal-panel w-full max-w-md rounded-3xl p-6 shadow-2xl space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-base font-bold text-white flex items-center gap-2">
<i data-lucide="clipboard-paste" class="w-5 h-5 text-accent"></i>
Colar JSON
</h3>
<button id="btn-close-paste" aria-label="Fechar" class="p-1.5 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<textarea id="input-paste-json" rows="8" placeholder="Cole aqui o conteúdo do arquivo .json..." class="w-full bg-slate-900/90 border border-white/15 rounded-xl px-3.5 py-2.5 text-xs text-white font-mono focus:outline-none focus:border-accent resize-y"></textarea>
<div class="flex items-center justify-end gap-2.5 pt-2 border-t border-white/10">
<button type="button" id="btn-cancel-paste" class="px-4 py-2 rounded-xl text-slate-300 hover:bg-white/10 transition text-xs font-semibold">Cancelar</button>
<button type="button" id="btn-confirm-paste" class="px-5 py-2 rounded-xl bg-accent hover:bg-accent-hover text-white font-semibold transition text-xs shadow-lg">Importar</button>
</div>
</div>
</div>
<!-- ==================== DRAWER DE CONFIGURAÇÕES ==================== -->
<div id="modal-settings" role="dialog" aria-modal="true" class="fixed inset-0 z-50 hidden flex justify-end bg-black/60 backdrop-blur-sm animate-fade-in">
<div class="w-full max-w-md h-full modal-panel p-6 overflow-y-auto border-l border-white/15 shadow-2xl flex flex-col justify-between">
<div class="space-y-6">
<div class="flex items-center justify-between pb-4 border-b border-white/10">
<div class="flex items-center gap-2.5">
<i data-lucide="sliders" class="w-5 h-5 text-accent"></i>
<h2 class="text-lg font-bold text-white">Configurações</h2>
</div>
<button id="btn-close-settings" aria-label="Fechar Configurações" class="p-1.5 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<!-- Perfil -->
<div class="space-y-2">
<label class="block text-xs uppercase tracking-wider font-semibold text-slate-400">Identificação</label>
<div>
<label class="block text-xs text-slate-300 mb-1">Seu Nome / Apelido</label>
<input type="text" id="setting-user-name" class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3.5 py-2 text-sm text-white focus:outline-none focus:border-accent">
</div>
<div>
<label class="block text-xs text-slate-300 mb-1">Cidade (para o clima)</label>
<input type="text" id="setting-city" placeholder="Ex: São Paulo, Rio de Janeiro..." class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3.5 py-2 text-sm text-white focus:outline-none focus:border-accent">
<p class="text-[10px] text-slate-500 mt-1">Deixe vazio para ocultar o clima ao lado do relógio.</p>
</div>
</div>
<!-- Fundo -->
<div class="space-y-3">
<label class="block text-xs uppercase tracking-wider font-semibold text-slate-400">Plano de Fundo</label>
<div>
<label class="block text-xs text-slate-300 mb-1">URL da Imagem de Fundo</label>
<div class="flex gap-2">
<input type="url" id="setting-bg-url" placeholder="https://images.unsplash.com/..." class="flex-1 bg-slate-900/80 border border-white/15 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-accent">
<button id="btn-apply-bg" class="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-xl text-xs font-semibold text-white transition">Aplicar</button>
</div>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1.5">Predefinições Rápidas</label>
<div class="grid grid-cols-4 gap-2">
<button type="button" class="preset-bg rounded-lg h-12 bg-cover bg-center border border-white/20 hover:scale-105 transition" style="background-image: url('https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600&q=70')" data-url="https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1920&q=85"></button>
<button type="button" class="preset-bg rounded-lg h-12 bg-cover bg-center border border-white/20 hover:scale-105 transition" style="background-image: url('https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=600&q=70')" data-url="https://images.unsplash.com/photo-1518709268805-4e9042af9f23?w=1920&q=85"></button>
<button type="button" class="preset-bg rounded-lg h-12 bg-cover bg-center border border-white/20 hover:scale-105 transition" style="background-image: url('https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=600&q=70')" data-url="https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1920&q=85"></button>
<button type="button" class="preset-bg rounded-lg h-12 bg-cover bg-center border border-white/20 hover:scale-105 transition" style="background-image: url('https://images.unsplash.com/photo-1579546929518-9e396f3cc809?w=600&q=70')" data-url="https://images.unsplash.com/photo-1579546929518-9e396f3cc809?w=1920&q=85"></button>
</div>
</div>
<div>
<div class="flex justify-between text-xs text-slate-300 mb-1">
<span>Escurecimento do Fundo</span>
<span id="val-bg-dim">60%</span>
</div>
<input type="range" id="setting-bg-dim" min="10" max="95" value="60" class="w-full accent-accent cursor-pointer">
</div>
</div>
<!-- Cores -->
<div class="space-y-2">
<label class="block text-xs uppercase tracking-wider font-semibold text-slate-400">Cor de Destaque</label>
<div class="flex items-center gap-3">
<button type="button" data-accent="#3b82f6" data-hover="#2563eb" class="theme-color w-7 h-7 rounded-full bg-blue-500 ring-2 ring-white/20 hover:scale-110 transition"></button>
<button type="button" data-accent="#10b981" data-hover="#059669" class="theme-color w-7 h-7 rounded-full bg-emerald-500 ring-2 ring-white/20 hover:scale-110 transition"></button>
<button type="button" data-accent="#8b5cf6" data-hover="#7c3aed" class="theme-color w-7 h-7 rounded-full bg-purple-500 ring-2 ring-white/20 hover:scale-110 transition"></button>
<button type="button" data-accent="#f43f5e" data-hover="#e11d48" class="theme-color w-7 h-7 rounded-full bg-rose-500 ring-2 ring-white/20 hover:scale-110 transition"></button>
<button type="button" data-accent="#f59e0b" data-hover="#d97706" class="theme-color w-7 h-7 rounded-full bg-amber-500 ring-2 ring-white/20 hover:scale-110 transition"></button>
<div class="flex items-center ml-auto">
<label for="setting-custom-color" class="text-[11px] text-slate-300 mr-2">Outra:</label>
<input type="color" id="setting-custom-color" value="#3b82f6" class="w-7 h-7 rounded-full cursor-pointer bg-transparent border-0">
</div>
</div>
</div>
<!-- Layout e Tamanho dos Cards -->
<div class="space-y-3">
<label class="block text-xs uppercase tracking-wider font-semibold text-slate-400">Estilo dos Atalhos</label>
<div class="grid grid-cols-3 gap-2">
<button type="button" data-layout="grid" class="layout-btn p-2.5 rounded-xl glass-panel text-center text-xs font-semibold text-white border border-transparent hover:border-accent transition flex flex-col items-center gap-1">
<i data-lucide="layout-grid" class="w-4 h-4"></i>
<span>Grade</span>
</button>
<button type="button" data-layout="dock" class="layout-btn p-2.5 rounded-xl glass-panel text-center text-xs font-semibold text-white border border-transparent hover:border-accent transition flex flex-col items-center gap-1">
<i data-lucide="panel-bottom" class="w-4 h-4"></i>
<span>Dock</span>
</button>
<button type="button" data-layout="list" class="layout-btn p-2.5 rounded-xl glass-panel text-center text-xs font-semibold text-white border border-transparent hover:border-accent transition flex flex-col items-center gap-1">
<i data-lucide="list" class="w-4 h-4"></i>
<span>Lista</span>
</button>
</div>
<div class="pt-1">
<label class="block text-xs text-slate-300 mb-1.5">Tamanho dos Cards</label>
<div class="grid grid-cols-3 gap-2">
<button type="button" data-size="sm" class="size-btn py-2 rounded-xl glass-panel text-xs text-center text-slate-200 hover:text-white transition">Pequeno</button>
<button type="button" data-size="md" class="size-btn py-2 rounded-xl glass-panel text-xs text-center text-slate-200 hover:text-white transition">Médio</button>
<button type="button" data-size="lg" class="size-btn py-2 rounded-xl glass-panel text-xs text-center text-slate-200 hover:text-white transition">Grande</button>
</div>
</div>
</div>
<!-- Sincronização na Nuvem -->
<div class="pt-3 border-t border-white/10 space-y-3">
<label class="block text-xs uppercase tracking-wider font-semibold text-slate-400">Sincronização na Nuvem</label>
<div class="rounded-2xl bg-white/5 border border-white/10 p-3 space-y-2.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-xl bg-accent/20 flex items-center justify-center">
<i data-lucide="cloud" class="w-4 h-4 text-accent"></i>
</div>
<div>
<p id="sync-status-title" class="text-xs font-semibold text-white">Não conectado</p>
<p id="sync-status-sub" class="text-[11px] text-slate-400">Faça login para sincronizar</p>
</div>
</div>
<span id="sync-badge" class="hidden px-2 py-0.5 rounded-full text-[10px] bg-emerald-500/20 text-emerald-300 font-semibold">Sincronizado</span>
</div>
<div class="grid grid-cols-2 gap-2">
<button id="btn-sync-login" class="flex items-center justify-center gap-2 px-3 py-2 rounded-xl bg-accent/90 hover:bg-accent text-xs font-semibold text-white transition">
<i data-lucide="log-in" class="w-3.5 h-3.5"></i> Entrar / Cadastrar
</button>
<button id="btn-sync-now" class="flex items-center justify-center gap-2 px-3 py-2 rounded-xl bg-white/10 hover:bg-white/20 text-xs font-semibold text-white transition disabled:opacity-40 disabled:pointer-events-none">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i> Sincronizar agora
</button>
</div>
<button id="btn-sync-logout" class="hidden w-full flex items-center justify-center gap-2 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-xs font-medium text-slate-300 transition">
<i data-lucide="log-out" class="w-3.5 h-3.5"></i> Sair da conta
</button>
</div>
</div>
<!-- Dados -->
<div class="pt-3 border-t border-white/10 space-y-3">
<label class="block text-xs uppercase tracking-wider font-semibold text-slate-400">Dados</label>
<div class="grid grid-cols-2 gap-2.5">
<button id="btn-export-json" class="flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl bg-white/10 hover:bg-white/20 text-xs font-semibold text-white transition">
<i data-lucide="download" class="w-4 h-4 text-emerald-400"></i> Exportar
</button>
<label class="flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl bg-white/10 hover:bg-white/20 text-xs font-semibold text-white transition cursor-pointer">
<i data-lucide="upload" class="w-4 h-4 text-cyan-400"></i> Importar
<input type="file" id="file-import-json" accept=".json" class="hidden">
</label>
</div>
<div class="grid grid-cols-2 gap-2.5">
<button id="btn-copy-json" class="flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl bg-white/10 hover:bg-white/20 text-xs font-semibold text-white transition">
<i data-lucide="copy" class="w-4 h-4 text-amber-400"></i> Copiar JSON
</button>
<button id="btn-paste-json" class="flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl bg-white/10 hover:bg-white/20 text-xs font-semibold text-white transition">
<i data-lucide="clipboard-paste" class="w-4 h-4 text-sky-400"></i> Colar JSON
</button>
</div>
</div>
</div>
<div class="pt-6 mt-6 border-t border-white/10">
<button id="btn-save-settings" class="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white font-semibold text-xs transition shadow-lg">
Concluir e Fechar
</button>
</div>
</div>
</div>
<!-- ==================== MODAL DE LOGIN/CADASTRO ==================== -->
<div id="modal-auth" role="dialog" aria-modal="true" class="fixed inset-0 z-[80] hidden flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-fade-in">
<div class="modal-panel w-full max-w-sm rounded-3xl p-6 shadow-2xl space-y-4">
<div class="flex items-center justify-between border-b border-white/10 pb-3">
<h3 class="text-base font-bold text-white flex items-center gap-2">
<i data-lucide="cloud" class="w-5 h-5 text-accent"></i>
Sincronizar na nuvem
</h3>
<button id="btn-close-auth-modal" aria-label="Fechar" class="p-1.5 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<!-- Abas Auth -->
<div class="flex items-center bg-white/5 rounded-xl p-1 text-xs">
<button type="button" data-auth-tab="login" class="auth-tab-btn flex-1 py-1.5 rounded-lg font-semibold text-white bg-white/15 transition">Entrar</button>
<button type="button" data-auth-tab="register" class="auth-tab-btn flex-1 py-1.5 rounded-lg font-semibold text-slate-300 hover:text-white transition">Criar conta</button>
</div>
<!-- Login -->
<form id="form-login" class="space-y-3 text-xs">
<div>
<label class="block text-slate-300 font-medium mb-1">E-mail</label>
<input type="email" id="login-email" required autocomplete="email" placeholder="seu@email.com" class="w-full bg-slate-900/90 border border-white/20 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
</div>
<div>
<label class="block text-slate-300 font-medium mb-1">Senha</label>
<input type="password" id="login-password" required autocomplete="current-password" placeholder="••••••••" class="w-full bg-slate-900/90 border border-white/20 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
</div>
<button type="submit" class="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white font-semibold transition shadow-lg">
<i data-lucide="log-in" class="w-4 h-4"></i> Entrar
</button>
<p id="login-error" class="text-rose-400 text-[11px] hidden"></p>
</form>
<!-- Registro -->
<form id="form-register" class="space-y-3 text-xs hidden">
<div>
<label class="block text-slate-300 font-medium mb-1">E-mail</label>
<input type="email" id="reg-email" required autocomplete="email" placeholder="seu@email.com" class="w-full bg-slate-900/90 border border-white/20 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
</div>
<div>
<label class="block text-slate-300 font-medium mb-1">Senha (mín. 6 caracteres)</label>
<input type="password" id="reg-password" required minlength="6" autocomplete="new-password" placeholder="••••••••" class="w-full bg-slate-900/90 border border-white/20 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
</div>
<input type="text" name="website" id="reg-honeypot" tabindex="-1" autocomplete="off" class="hidden">
<button type="submit" class="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white font-semibold transition shadow-lg">
<i data-lucide="user-plus" class="w-4 h-4"></i> Criar conta
</button>
<p id="reg-error" class="text-rose-400 text-[11px] hidden"></p>
</form>
<p class="text-[11px] text-slate-400 leading-relaxed">
Seus atalhos, categorias, tarefas, notas e configurações são sincronizados com segurança na sua conta.
</p>
</div>
</div>
<!-- ==================== MODAL DE MERGE SYNC ==================== -->
<div id="modal-sync-merge" role="dialog" aria-modal="true" class="fixed inset-0 z-[90] hidden flex items-center justify-center p-4 bg-black/90 backdrop-blur-md animate-fade-in">
<div class="modal-panel w-full max-w-sm rounded-3xl p-6 shadow-2xl space-y-4 text-center">
<div class="w-12 h-12 rounded-full bg-accent/20 text-accent flex items-center justify-center mx-auto">
<i data-lucide="cloud-download" class="w-6 h-6"></i>
</div>
<div>
<h3 class="text-base font-bold text-white">Dados encontrados na nuvem</h3>
<p class="text-xs text-slate-300 mt-2 leading-relaxed">
Há configurações salvas nessa conta. O que deseja fazer?
</p>
</div>
<div class="space-y-2 pt-2">
<button id="btn-sync-download" class="w-full px-4 py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white font-semibold text-xs transition shadow-lg flex items-center justify-center gap-2">
<i data-lucide="download-cloud" class="w-4 h-4"></i> Substituir local pelos dados da nuvem
</button>
<button id="btn-sync-upload" class="w-full px-4 py-2.5 rounded-xl bg-white/10 hover:bg-white/20 text-white font-semibold text-xs transition flex items-center justify-center gap-2">
<i data-lucide="upload-cloud" class="w-4 h-4"></i> Enviar dados locais para a nuvem
</button>
<button id="btn-sync-cancel" class="w-full px-4 py-2 rounded-xl text-slate-300 hover:bg-white/10 transition text-xs font-semibold">
Cancelar
</button>
</div>
</div>
</div>
<!-- ==================== PAINEL DE TAREFAS (slide esquerda) ==================== -->
<div id="panel-tasks" role="dialog" aria-modal="true" class="fixed inset-0 z-[55] hidden">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" data-close-panel="panel-tasks"></div>
<div class="absolute left-0 top-0 h-full w-full sm:max-w-md modal-panel border-r border-white/15 flex flex-col">
<div class="flex items-center justify-between p-5 border-b border-white/10 flex-shrink-0">
<h2 class="text-base font-bold text-white flex items-center gap-2">
<i data-lucide="clipboard-list" class="w-5 h-5 text-accent"></i>
Tarefas
</h2>
<button id="btn-close-tasks" aria-label="Fechar Tarefas" class="p-1.5 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="px-5 py-3 flex-shrink-0">
<button id="btn-new-task" class="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-xs font-semibold transition shadow flex items-center justify-center gap-2">
<i data-lucide="plus" class="w-4 h-4"></i> Nova Tarefa
</button>
</div>
<div id="tasks-list" class="flex-1 overflow-y-auto px-5 pb-5 space-y-2">
<!-- Itens renderizados dinamicamente -->
</div>
</div>
</div>
<!-- ==================== MODAL DETALHE DA TAREFA ==================== -->
<div id="modal-task-detail" role="dialog" aria-modal="true" class="fixed inset-0 z-[65] hidden flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-fade-in">
<div class="modal-panel w-full max-w-md rounded-3xl p-6 shadow-2xl relative max-h-[90vh] overflow-y-auto">
<button id="btn-close-task-detail" aria-label="Fechar" class="absolute top-4 right-4 p-1.5 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition z-10">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
<div id="task-detail-content">
<!-- Conteúdo renderizado dinamicamente -->
</div>
</div>
</div>
<!-- ==================== MODAL FORMULÁRIO DE TAREFA ==================== -->
<div id="modal-task-form" role="dialog" aria-modal="true" class="fixed inset-0 z-[65] hidden flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-fade-in">
<div class="modal-panel w-full max-w-md rounded-3xl p-6 shadow-2xl relative max-h-[92vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 id="task-form-title" class="text-lg font-bold text-white flex items-center gap-2">
<i data-lucide="clipboard-plus" class="w-5 h-5 text-accent"></i>
Nova Tarefa
</h3>
<button id="btn-close-task-form" aria-label="Fechar" class="p-1.5 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form id="form-task" class="space-y-3.5 text-xs">
<input type="hidden" id="input-task-id">
<div>
<label class="block text-slate-300 font-medium mb-1">Nome da Tarefa *</label>
<input type="text" id="input-task-name" required placeholder="Ex: Reunião de projeto, Academia..." class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
</div>
<div>
<label class="block text-slate-300 font-medium mb-1">Descrição</label>
<textarea id="input-task-desc" rows="2" placeholder="Detalhes sobre a tarefa..." class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none focus:border-accent resize-y"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-slate-300 font-medium mb-1">Data *</label>
<input type="date" id="input-task-date" required class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
</div>
<div>
<label class="block text-slate-300 font-medium mb-1">Prioridade *</label>
<select id="input-task-priority" required class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3 py-2.5 text-sm text-white focus:outline-none focus:border-accent">
<option value="low">Baixa</option>
<option value="medium" selected>Média</option>
<option value="high">Alta</option>
</select>
</div>
</div>
<div>
<label class="block text-slate-300 font-medium mb-1">Checklist</label>
<div id="checklist-editor" class="space-y-2 mb-2">
<!-- Itens do checklist renderizados dinamicamente -->
</div>
<button type="button" id="btn-add-check-item" class="flex items-center gap-1.5 text-accent hover:text-accent-hover text-xs font-semibold transition">
<i data-lucide="plus" class="w-3.5 h-3.5"></i> Adicionar item
</button>
</div>
<div class="flex items-center justify-end gap-2.5 pt-4 border-t border-white/10">
<button type="button" id="btn-cancel-task" class="px-4 py-2 rounded-xl text-slate-300 hover:bg-white/10 transition">Cancelar</button>
<button type="submit" class="px-5 py-2 rounded-xl bg-accent hover:bg-accent-hover text-white font-semibold transition shadow-lg flex items-center gap-2">
<i data-lucide="check" class="w-4 h-4"></i> Salvar
</button>
</div>
</form>
</div>
</div>
<!-- ==================== MODAL EXCLUSÃO DE TAREFA ==================== -->
<div id="modal-task-delete" role="dialog" aria-modal="true" class="fixed inset-0 z-[70] hidden flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-fade-in">
<div class="modal-panel w-full max-w-sm rounded-3xl p-6 shadow-2xl border border-rose-500/30 text-center space-y-4">
<div class="w-12 h-12 rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center mx-auto">
<i data-lucide="alert-triangle" class="w-6 h-6"></i>
</div>
<div>
<h3 class="text-base font-bold text-white">Excluir esta tarefa?</h3>
<p id="task-delete-text" class="text-xs text-slate-300 mt-1">Essa ação não pode ser desfeita.</p>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button type="button" id="btn-cancel-task-delete" class="px-4 py-2 rounded-xl text-slate-300 hover:bg-white/10 transition text-xs font-semibold">Cancelar</button>
<button type="button" id="btn-confirm-task-delete" class="px-5 py-2 rounded-xl bg-rose-600 hover:bg-rose-500 text-white font-semibold transition text-xs shadow-lg">Excluir</button>
</div>
</div>
</div>
<!-- ==================== PAINEL DE NOTAS (slide esquerda) ==================== -->
<div id="panel-notes" role="dialog" aria-modal="true" class="fixed inset-0 z-[55] hidden">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in" data-close-panel="panel-notes"></div>
<div class="absolute left-0 top-0 h-full w-full sm:max-w-md modal-panel border-r border-white/15 flex flex-col">
<div class="flex items-center justify-between p-5 border-b border-white/10 flex-shrink-0">
<h2 class="text-base font-bold text-white flex items-center gap-2">
<i data-lucide="sticky-note" class="w-5 h-5" style="color: var(--accent-color);"></i>
Notas
</h2>
<button id="btn-close-notes" aria-label="Fechar Notas" class="p-1.5 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="px-5 py-3 flex-shrink-0">
<button id="btn-new-note" class="w-full py-2.5 rounded-xl text-slate-900 text-xs font-bold transition shadow flex items-center justify-center gap-2" style="background: var(--accent-color);">
<i data-lucide="plus" class="w-4 h-4"></i> Nova Nota
</button>
</div>
<div id="notes-list" class="flex-1 overflow-y-auto px-5 pb-5 space-y-2">
<!-- Itens renderizados dinamicamente -->
</div>
</div>
</div>
<!-- ==================== MODAL FORMULÁRIO DE NOTA ==================== -->
<div id="modal-note-form" role="dialog" aria-modal="true" class="fixed inset-0 z-[65] hidden flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-fade-in">
<div class="modal-panel w-full max-w-md rounded-3xl p-6 shadow-2xl relative max-h-[92vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 id="note-form-title" class="text-lg font-bold text-white flex items-center gap-2">
<i data-lucide="sticky-note" class="w-5 h-5" style="color: var(--accent-color);"></i>
Nova Nota
</h3>
<button id="btn-close-note-form" aria-label="Fechar" class="p-1.5 rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<form id="form-note" class="space-y-3.5 text-xs">
<input type="hidden" id="input-note-id">
<div>
<label class="block text-slate-300 font-medium mb-1">Título *</label>
<input type="text" id="input-note-name" required placeholder="Ex: Ideias, Lembretes, Anotações..." class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none" style="--focus-border: var(--accent-color);">
</div>
<div>
<label class="block text-slate-300 font-medium mb-1">Conteúdo *</label>
<textarea id="input-note-desc" rows="8" required placeholder="Escreva sua nota aqui..." class="w-full bg-slate-900/80 border border-white/15 rounded-xl px-3.5 py-2.5 text-sm text-white focus:outline-none resize-y" style="--focus-border: var(--accent-color);"></textarea>
</div>
<p id="note-created-info" class="hidden text-[10px] text-slate-500"></p>
<div class="flex items-center justify-end gap-2.5 pt-4 border-t border-white/10">
<button type="button" id="btn-cancel-note" class="px-4 py-2 rounded-xl text-slate-300 hover:bg-white/10 transition">Cancelar</button>
<button type="submit" class="px-5 py-2 rounded-xl text-slate-900 font-bold transition shadow-lg flex items-center gap-2" style="background: var(--accent-color);">
<i data-lucide="check" class="w-4 h-4"></i> Salvar
</button>
</div>
</form>
</div>
</div>
<!-- ==================== MODAL EXCLUSÃO DE NOTA ==================== -->
<div id="modal-note-delete" role="dialog" aria-modal="true" class="fixed inset-0 z-[70] hidden flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-fade-in">
<div class="modal-panel w-full max-w-sm rounded-3xl p-6 shadow-2xl border border-rose-500/30 text-center space-y-4">
<div class="w-12 h-12 rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center mx-auto">
<i data-lucide="alert-triangle" class="w-6 h-6"></i>
</div>
<div>
<h3 class="text-base font-bold text-white">Excluir esta nota?</h3>
<p id="note-delete-text" class="text-xs text-slate-300 mt-1">Essa ação não pode ser desfeita.</p>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button type="button" id="btn-cancel-note-delete" class="px-4 py-2 rounded-xl text-slate-300 hover:bg-white/10 transition text-xs font-semibold">Cancelar</button>
<button type="button" id="btn-confirm-note-delete" class="px-5 py-2 rounded-xl bg-rose-600 hover:bg-rose-500 text-white font-semibold transition text-xs shadow-lg">Excluir</button>
</div>
</div>
</div>
<!-- ==================== JAVASCRIPT E LÓGICA ==================== -->
<script src="app.js" defer></script>
</body>
</html>

19
web/manifest.json Normal file
View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Política de Privacidade · InitPage</title>
<meta name="description" content="Política de Privacidade do InitPage, página inicial personalizável do navegador.">
<meta name="robots" content="index, follow">
<link rel="icon" href="https://www.google.com/s2/favicons?domain=weltonmoura.com.br&sz=128">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="../styles.css">
<style>
body {
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
}
a.accent {
color: var(--accent-color);
}
a.accent:hover {
text-decoration: underline;
}
</style>
</head>
<body class="bg-slate-950 text-slate-300 antialiased min-h-screen flex flex-col">
<div class="fixed inset-0 bg-cover bg-center -z-20 opacity-20" style="background-image: url('https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1920&q=85');"></div>
<header class="w-full px-6 py-6 flex items-center justify-between">
<h1 class="text-lg font-extrabold text-white tracking-tight items-center">
<span class="text-accent">InitPage</span> · Política de Privacidade
</h1>
<a href="https://weltonmoura.com.br" class="text-xs text-slate-400 hover:text-white transition">weltonmoura.com.br</a>
</header>
<main class="w-full max-w-3xl mx-auto px-6 pb-14 flex-1">
<section class="glass-panel rounded-3xl p-7 sm:p-10 space-y-8 border border-white/10">
<div class="pb-4 border-b border-white/10">
<p class="text-xs uppercase tracking-wider font-semibold text-slate-400 mb-2">Última atualização: 18 de outubro de 2026</p>
<p class="text-sm leading-relaxed">
Esta Política de Privacidade descreve como o aplicativo web <strong class="text-white">InitPage</strong>
(doravante "o aplicativo") coleta, usa e armazena informações. Ao utilizar o aplicativo, você concorda
com as práticas descritas neste documento.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">1. Visão geral</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">2. Dados armazenados localmente</h2>
<p class="text-sm leading-relaxed">
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
(<code class="text-slate-200 bg-white/10 rounded px-1.5 py-0.5 text-xs">localStorage</code>).
</p>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">3. Backup e exportação</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">4. Conteúdo patrocinado</h2>
<p class="text-sm leading-relaxed">
Em certas categorias, o aplicativo pode exibir cards identificados como <strong class="text-white">"Patrocinado"</strong>.
Esses cards são injetados durante a renderização da página e <em>não</em> fazem parte dos seus dados:
eles não são salvos nem exportados junto com suas configurações.
</p>
<p class="text-sm leading-relaxed">
A seleção do anúncio é feita <strong class="text-white">exclusivamente no seu navegador</strong>, com
base no nome da categoria ativa. Nenhum dado pessoal (nome, atalhos, histórico) é enviado ou usado
para escolher o anúncio a exibir.
</p>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">5. Serviços de terceiros</h2>
<p class="text-sm leading-relaxed">
O aplicativo consulta a API <strong class="text-white">Open-Meteo</strong> (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.
</p>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">6. Cookies e rastreamento</h2>
<p class="text-sm leading-relaxed">
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).
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">7. Compartilhamento de dados</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">8. Segurança</h2>
<p class="text-sm leading-relaxed">
Todo o tráfego ocorre sobre HTTPS. Seus dados são armazenados apenas no <code class="text-slate-200 bg-white/10 rounded px-1.5 py-0.5 text-xs">localStorage</code>
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 <code class="text-slate-200 bg-white/10 rounded px-1.5 py-0.5 text-xs">javascript:</code>).
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">9. Seus direitos (LGPD)</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">10. Alterações nesta política</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">11. Contato</h2>
<p class="text-sm leading-relaxed">
Dúvidas sobre esta Política de Privacidade podem ser enviadas para:
<a class="accent" href="mailto:weltonmouradev@gmail.com">weltonmouradev@gmail.com</a>.
</p>
</div>
</section>
<footer class="mt-8 text-center text-xs text-slate-500 space-x-4">
<a class="accent" href="https://weltonmoura.com.br/initpage/termos-servico/">Termos de Serviço</a>
<span>•</span>
<a class="accent" href="https://weltonmoura.com.br/initpage/">Voltar ao início</a>
</footer>
</main>
</body>
</html>

5
web/robots.txt Normal file
View File

@@ -0,0 +1,5 @@
User-agent: *
Allow: /
Disallow: /initpage/sw.js
Sitemap: https://weltonmoura.com.br/sitemap.xml

265
web/styles.css Normal file
View File

@@ -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;
}
}

41
web/sw.js Normal file
View File

@@ -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;
})
);
});

View File

@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Termos de Serviço · InitPage</title>
<meta name="description" content="Termos de Serviço do InitPage, página inicial personalizável do navegador.">
<meta name="robots" content="index, follow">
<link rel="icon" href="https://www.google.com/s2/favicons?domain=weltonmoura.com.br&sz=128">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="../styles.css">
<style>
body {
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif;
}
a.accent {
color: var(--accent-color);
}
a.accent:hover {
text-decoration: underline;
}
</style>
</head>
<body class="bg-slate-950 text-slate-300 antialiased min-h-screen flex flex-col">
<div class="fixed inset-0 bg-cover bg-center -z-20 opacity-20" style="background-image: url('https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1920&q=85');"></div>
<header class="w-full px-6 py-6 flex items-center justify-between">
<h1 class="text-lg font-extrabold text-white tracking-tight">
<span class="text-accent">InitPage</span> · Termos de Serviço
</h1>
<a href="https://weltonmoura.com.br" class="text-xs text-slate-400 hover:text-white transition">weltonmoura.com.br</a>
</header>
<main class="w-full max-w-3xl mx-auto px-6 pb-14 flex-1">
<section class="glass-panel rounded-3xl p-7 sm:p-10 space-y-8 border border-white/10">
<div class="pb-4 border-b border-white/10">
<p class="text-xs uppercase tracking-wider font-semibold text-slate-400 mb-2">Última atualização: 18 de outubro de 2026</p>
<p class="text-sm leading-relaxed">
Estes Termos de Serviço regem o uso do aplicativo web <strong class="text-white">InitPage</strong>
(doravante "o aplicativo"). Ao utilizar o aplicativo, você declara ter lido, entendido e aceitado
estes termos.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">1. Descrição do serviço</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">2. Aceitação dos termos</h2>
<p class="text-sm leading-relaxed">
O acesso e o uso do aplicativo implicam na aceitação integral destes Termos. Se você não concorda,
não utilize o aplicativo.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">3. Uso permitido</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">4. Backup e exportação de dados</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">5. Responsabilidades do usuário</h2>
<ul class="text-sm leading-relaxed list-disc pl-5 space-y-1.5">
<li>Ser o único responsável pelas informações salvas localmente no seu navegador;</li>
<li>Manter cópias de segurança (exportação/backup) se desejar preservar suas configurações;</li>
<li>Não utilizar o aplicativo para armazenar ou divulgar conteúdos ilegais.</li>
</ul>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">6. Conteúdo patrocinado</h2>
<p class="text-sm leading-relaxed">
O aplicativo pode exibir cards identificados como <strong class="text-white">"Patrocinado"</strong> 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.
</p>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">7. Propriedade intelectual</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">8. Disponibilidade e garantias</h2>
<p class="text-sm leading-relaxed">
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).
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">9. Limitação de responsabilidade</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">10. Alterações dos termos</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">11. Lei aplicável</h2>
<p class="text-sm leading-relaxed">
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.
</p>
</div>
<div class="space-y-2">
<h2 class="text-base font-bold text-white">12. Contato</h2>
<p class="text-sm leading-relaxed">
Dúvidas sobre estes Termos podem ser enviadas para:
<a class="accent" href="mailto:weltonmouradev@gmail.com">weltonmouradev@gmail.com</a>.
</p>
</div>
</section>
<footer class="mt-8 text-center text-xs text-slate-500 space-x-4">
<a class="accent" href="https://weltonmoura.com.br/initpage/politica-privacidade/">Política de Privacidade</a>
<span>•</span>
<a class="accent" href="https://weltonmoura.com.br/initpage/">Voltar ao início</a>
</footer>
</main>
</body>
</html>