feat: initpage com sync Go + auth + frontend em web/
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal 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
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/server
|
||||||
|
/data/*.db
|
||||||
|
/data/*.db-*
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal 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
97
cmd/server/main.go
Normal 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
12
docker-compose.yml
Normal 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
28
go.mod
Normal 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
59
go.sum
Normal 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
65
internal/auth/password.go
Normal 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
34
internal/config/config.go
Normal 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
41
internal/db/db.go
Normal 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
121
internal/handlers/auth.go
Normal 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)
|
||||||
|
}
|
||||||
45
internal/middleware/auth.go
Normal file
45
internal/middleware/auth.go
Normal 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 ""
|
||||||
|
}
|
||||||
66
internal/middleware/ratelimit.go
Normal file
66
internal/middleware/ratelimit.go
Normal 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
91
internal/sync/sync.go
Normal 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
153
web/ads.js
Normal 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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconFallback(ad) {
|
||||||
|
return safeUrl(ad.icon) || `https://www.google.com/s2/favicons?domain=${encodeURIComponent(ad.url)}&sz=128`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCoverHtml(ad) {
|
||||||
|
const cover = safeUrl(ad.cover);
|
||||||
|
if (!cover) {
|
||||||
|
return '<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
2392
web/app.js
Normal file
File diff suppressed because it is too large
Load Diff
16
web/icon.svg
Normal file
16
web/icon.svg
Normal 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
899
web/index.html
Normal 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
19
web/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
167
web/politica-privacidade/index.html
Normal file
167
web/politica-privacidade/index.html
Normal 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
5
web/robots.txt
Normal 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
265
web/styles.css
Normal 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
41
web/sw.js
Normal 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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
162
web/termos-servico/index.html
Normal file
162
web/termos-servico/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user