Initial commit. Working login, example UI with prototype and demo, connections to DB and DAV, local and prod setups.
This commit is contained in:
+99
@@ -0,0 +1,99 @@
|
|||||||
|
# ------------------------------------
|
||||||
|
# 1. Project-wide Secrets and Config
|
||||||
|
# ------------------------------------
|
||||||
|
|
||||||
|
# Environment/Secrets files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.env_shared
|
||||||
|
*/.env
|
||||||
|
*/.env.*
|
||||||
|
|
||||||
|
# Local Certificate files
|
||||||
|
nginx/certs/
|
||||||
|
|
||||||
|
# Docker volumes
|
||||||
|
pgdata/
|
||||||
|
|
||||||
|
# ------------------------------------
|
||||||
|
# 2. Go (Backend)
|
||||||
|
# ------------------------------------
|
||||||
|
|
||||||
|
# Compiled binaries
|
||||||
|
backend/bin/
|
||||||
|
backend/vendor/
|
||||||
|
|
||||||
|
# macOS/Linux standard binary names
|
||||||
|
backend/app
|
||||||
|
backend/main
|
||||||
|
|
||||||
|
# Dependency management cache
|
||||||
|
backend/.DS_Store
|
||||||
|
|
||||||
|
# Go build cache
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Go test outputs
|
||||||
|
*.out
|
||||||
|
!go.mod
|
||||||
|
!go.sum
|
||||||
|
|
||||||
|
# ------------------------------------
|
||||||
|
# 3. Node/Svelte (Frontend)
|
||||||
|
# ------------------------------------
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/package-lock.json
|
||||||
|
|
||||||
|
# SvelteKit/Vite build artifacts and cache
|
||||||
|
frontend/.svelte-kit
|
||||||
|
frontend/.vite
|
||||||
|
frontend/.svelte2tsx-language-server-files
|
||||||
|
frontend/build/
|
||||||
|
frontend/dist/
|
||||||
|
|
||||||
|
# ------------------------------------
|
||||||
|
# 4. PHP/SabreDAV
|
||||||
|
# ------------------------------------
|
||||||
|
|
||||||
|
# Composer dependencies
|
||||||
|
sabredav/vendor/
|
||||||
|
|
||||||
|
# ------------------------------------
|
||||||
|
# 5. Local Tools and Notes
|
||||||
|
# ------------------------------------
|
||||||
|
|
||||||
|
# Obsidian notes and config
|
||||||
|
obsidian/
|
||||||
|
|
||||||
|
# Bruno testing environments (keep collection files, ignore secrets)
|
||||||
|
bruno/*/environments/
|
||||||
|
|
||||||
|
# IDE files (e.g., VS Code)
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS-generated files
|
||||||
|
.DS_Store
|
||||||
|
.Trash/
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# ------------------------------------
|
||||||
|
# Git-specific
|
||||||
|
# ------------------------------------
|
||||||
|
*.orig # Git merge conflict files
|
||||||
|
|
||||||
|
# ------------------------------------
|
||||||
|
# Database dumps/backups
|
||||||
|
# ------------------------------------
|
||||||
|
*.sql.gz
|
||||||
|
*.dump
|
||||||
|
|
||||||
|
# ------------------------------------
|
||||||
|
# Nginx
|
||||||
|
# ------------------------------------
|
||||||
|
nginx/logs/
|
||||||
|
nginx/*.log
|
||||||
|
|
||||||
|
# Temp files
|
||||||
|
frontend/node_modules/.vite-temp
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
FROM alpine:latest
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy in your prebuilt Go binary (from local ./backend/bin/backend)
|
||||||
|
COPY bin/backend ./backend
|
||||||
|
|
||||||
|
# Copy env file if you want to bake it in (or mount via volume/env_file in compose)
|
||||||
|
COPY .env ./
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["./backend"]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// auth/jwt.go
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/jwtauth/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var TokenAuth *jwtauth.JWTAuth
|
||||||
|
|
||||||
|
// AuthResponse is the response structure for login/refresh endpoints
|
||||||
|
type AuthResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitJWT(secret string) {
|
||||||
|
TokenAuth = jwtauth.New("HS256", []byte(secret), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateToken creates a JWT with user_id and role
|
||||||
|
func GenerateToken(userID string, role string) (string, error) {
|
||||||
|
_, tokenString, err := TokenAuth.Encode(map[string]interface{}{
|
||||||
|
"user_id": userID,
|
||||||
|
"role": role,
|
||||||
|
"exp": time.Now().Add(30 * 24 * time.Hour).Unix(), // 30 days
|
||||||
|
})
|
||||||
|
return tokenString, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyToken validates JWT and returns user_id and role
|
||||||
|
func VerifyToken(tokenString string, ctx context.Context) (userID string, role string, err error) {
|
||||||
|
token, err := TokenAuth.Decode(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := token.AsMap(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := claims["user_id"].(string)
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("invalid user_id claim")
|
||||||
|
}
|
||||||
|
|
||||||
|
role, ok = claims["role"].(string)
|
||||||
|
if !ok {
|
||||||
|
return "", "", fmt.Errorf("invalid role claim")
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, role, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package auth
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//go:build !dev
|
||||||
|
// +build !dev
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *pgxpool.Pool
|
||||||
|
|
||||||
|
func Connect() error {
|
||||||
|
// Connect to Postgres on local network (127.x.x.x)
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"postgres://%s:%s@%s:5432/%s",
|
||||||
|
getEnv("POSTGRES_USER"),
|
||||||
|
getEnv("POSTGRES_PASSWORD"),
|
||||||
|
getEnv("POSTGRES_HOST"),
|
||||||
|
getEnv("POSTGRES_DB"),
|
||||||
|
)
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
DB = pool
|
||||||
|
|
||||||
|
err = testDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDB() error {
|
||||||
|
ctx := context.Background()
|
||||||
|
conn, err := DB.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Release()
|
||||||
|
|
||||||
|
row := conn.QueryRow(ctx, "SELECT 1")
|
||||||
|
var result int
|
||||||
|
err = row.Scan(&result)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key string) string {
|
||||||
|
if val := os.Getenv(key); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
log.Fatal("FATAL: Environment variable not set:", key)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
//go:build dev
|
||||||
|
// +build dev
|
||||||
|
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *pgxpool.Pool
|
||||||
|
|
||||||
|
func Connect() error {
|
||||||
|
// Connect to Postgres inside Docker network
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"postgres://%s:%s@localhost:5432/%s?sslmode=disable",
|
||||||
|
getEnv("POSTGRES_USER"),
|
||||||
|
getEnv("POSTGRES_PASSWORD"),
|
||||||
|
getEnv("POSTGRES_DB"),
|
||||||
|
)
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
DB = pool
|
||||||
|
|
||||||
|
err = testDB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDB() error {
|
||||||
|
ctx := context.Background()
|
||||||
|
conn, err := DB.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Release()
|
||||||
|
|
||||||
|
row := conn.QueryRow(ctx, "SELECT 1")
|
||||||
|
var result int
|
||||||
|
err = row.Scan(&result)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key string) string {
|
||||||
|
if val := os.Getenv(key); val != "" {
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
log.Fatal("FATAL: Environment variable not set:", key)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
module crussell
|
||||||
|
|
||||||
|
go 1.25.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/jwtauth/v5 v5.3.3
|
||||||
|
golang.org/x/text v0.30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 // indirect
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 // indirect
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
|
||||||
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
|
github.com/nyaruka/phonenumbers v1.6.6
|
||||||
|
github.com/segmentio/asm v1.2.1 // indirect
|
||||||
|
golang.org/x/crypto v0.43.0
|
||||||
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
|
github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo=
|
||||||
|
github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||||
|
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
|
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
|
||||||
|
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
|
||||||
|
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
|
||||||
|
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||||
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
|
github.com/nyaruka/phonenumbers v1.6.6 h1:cZv5/vslJh65zuOrLjdVDHKHzVEwVuUsXAPQi3bjGJU=
|
||||||
|
github.com/nyaruka/phonenumbers v1.6.6/go.mod h1:7gjs+Lchqm49adhAKB5cdcng5ZXgt6x7Jgvi0ZorUtU=
|
||||||
|
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/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b h1:18qgiDvlvH7kk8Ioa8Ov+K6xCi0GMvmGfGW0sgd/SYA=
|
||||||
|
golang.org/x/exp v0.0.0-20251009144603-d2f985daa21b/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
|
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package admin
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package admin
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package admin
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crussell/auth"
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/internal/dav"
|
||||||
|
"crussell/mw"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/mail"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nyaruka/phonenumbers"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
titleCaser = cases.Title(language.English)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Login state management
|
||||||
|
var (
|
||||||
|
loginStateMu sync.Mutex
|
||||||
|
loginInProgress = make(map[string]bool)
|
||||||
|
loginAttempts = make(map[string]time.Time)
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(1 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
loginStateMu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for userID, lastAttempt := range loginAttempts {
|
||||||
|
// Remove attempts older than 1 hour
|
||||||
|
if now.Sub(lastAttempt) > 1*time.Hour {
|
||||||
|
delete(loginAttempts, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loginStateMu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
FirstName string `json:"firstName"`
|
||||||
|
LastName string `json:"lastName"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
DateOfBirth string `json:"dateOfBirth"`
|
||||||
|
AgreedToPolicy bool `json:"agreedToPolicy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/register
|
||||||
|
func RegisterHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req RegisterRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must accept terms
|
||||||
|
if !req.AgreedToPolicy {
|
||||||
|
http.Error(w, "must agree to terms", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize input
|
||||||
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
req.FirstName = strings.TrimSpace(req.FirstName)
|
||||||
|
req.LastName = strings.TrimSpace(req.LastName)
|
||||||
|
req.Phone = strings.TrimSpace(req.Phone)
|
||||||
|
req.DateOfBirth = strings.TrimSpace(req.DateOfBirth)
|
||||||
|
|
||||||
|
// Check required fields
|
||||||
|
if req.FirstName == "" || req.LastName == "" || req.Email == "" || req.Phone == "" || req.DateOfBirth == "" {
|
||||||
|
http.Error(w, "all fields are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name (unicode letters, spaces, hyphen, apostrophe, dot)
|
||||||
|
nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`)
|
||||||
|
|
||||||
|
if !nameRegex.MatchString(req.FirstName) {
|
||||||
|
http.Error(w, "invalid characters in name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate length
|
||||||
|
if len(req.FirstName) > 50 || len(req.FirstName) < 1 {
|
||||||
|
http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.LastName) > 50 || len(req.LastName) < 1 {
|
||||||
|
http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
_, err := mail.ParseAddress(req.Email)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid email format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize phone (remove spaces, hyphens, parentheses)
|
||||||
|
req.Phone = strings.Map(func(r rune) rune {
|
||||||
|
if r >= '0' && r <= '9' || r == '+' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, req.Phone)
|
||||||
|
|
||||||
|
// Validate UK phone number
|
||||||
|
phone, err := ValidateUKPhoneNumber(req.Phone)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid phone number format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Phone = strings.TrimSpace(phone)
|
||||||
|
|
||||||
|
// Convert names to title case
|
||||||
|
req.FirstName = titleCaser.String(strings.ToLower(req.FirstName))
|
||||||
|
req.LastName = titleCaser.String(strings.ToLower(req.LastName))
|
||||||
|
|
||||||
|
// Parse date of birth
|
||||||
|
dob, err := time.Parse("2006-01-02", req.DateOfBirth)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid date format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if younger than 16
|
||||||
|
if !dob.Before(time.Now().AddDate(-16, 0, 0)) {
|
||||||
|
http.Error(w, "account creation prohibited for users under 16. Please call to book an appointment.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.DB.Begin(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
http.Error(w, "server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback(r.Context())
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Insert and return the generated ID
|
||||||
|
var userID string
|
||||||
|
err = tx.QueryRow(r.Context(), `
|
||||||
|
INSERT INTO users
|
||||||
|
(n_first_name, n_last_name, phone, date_of_birth, email, password_hash,
|
||||||
|
account_type, privacy_policy_and_terms_consent, policy_consent_updated_at,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES
|
||||||
|
($1, $2, $3, $4, $5, $6, 'email', $7, $8, $8, $8)
|
||||||
|
RETURNING id
|
||||||
|
`, req.FirstName, req.LastName, req.Phone, dob, req.Email, string(hash), req.AgreedToPolicy, now).Scan(&userID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
if strings.Contains(err.Error(), "duplicate key") {
|
||||||
|
http.Error(w, "an account with this email already exists", http.StatusConflict)
|
||||||
|
} else {
|
||||||
|
http.Error(w, "could not create user", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(r.Context()); err != nil {
|
||||||
|
http.Error(w, "server error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
input := dav.ContactInput{
|
||||||
|
UserID: userID,
|
||||||
|
FirstName: req.FirstName,
|
||||||
|
LastName: req.LastName,
|
||||||
|
Email: req.Email,
|
||||||
|
Phone: req.Phone,
|
||||||
|
DOB: req.DateOfBirth,
|
||||||
|
}
|
||||||
|
if err := dav.Service.CreateContact(1, userID, input); err != nil {
|
||||||
|
log.Printf("Warning: Failed to create contact in DAV for user %s: %v", userID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateUKPhoneNumber(phone string) (string, error) {
|
||||||
|
num, err := phonenumbers.Parse(phone, "GB")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !phonenumbers.IsValidNumber(num) {
|
||||||
|
return "", fmt.Errorf("invalid phone number")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's actually a UK number
|
||||||
|
if phonenumbers.GetRegionCodeForNumber(num) != "GB" {
|
||||||
|
return "", fmt.Errorf("only UK numbers allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format in E.164 format (+44...)
|
||||||
|
return phonenumbers.Format(num, phonenumbers.E164), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/login
|
||||||
|
func LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req LoginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize email
|
||||||
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
|
||||||
|
var userID, passwordHash, role string
|
||||||
|
ctx := context.Background()
|
||||||
|
err := db.DB.QueryRow(ctx, `
|
||||||
|
SELECT id, password_hash, account_role
|
||||||
|
FROM users
|
||||||
|
WHERE email = $1 AND account_type = 'email'
|
||||||
|
`, req.Email).Scan(&userID, &passwordHash, &role)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is already logging in
|
||||||
|
loginStateMu.Lock()
|
||||||
|
if loginInProgress[userID] {
|
||||||
|
loginStateMu.Unlock()
|
||||||
|
http.Error(w, "login already in progress", http.StatusConflict) // 409
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loginInProgress[userID] = true
|
||||||
|
loginStateMu.Unlock()
|
||||||
|
|
||||||
|
// Always clear flag when done
|
||||||
|
defer func() {
|
||||||
|
loginStateMu.Lock()
|
||||||
|
delete(loginInProgress, userID)
|
||||||
|
loginStateMu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Enforce 1 attempt per 5s
|
||||||
|
loginStateMu.Lock()
|
||||||
|
if last, ok := loginAttempts[userID]; ok {
|
||||||
|
since := time.Since(last)
|
||||||
|
if since < 5*time.Second {
|
||||||
|
wait := 5*time.Second - since
|
||||||
|
loginStateMu.Unlock()
|
||||||
|
time.Sleep(wait)
|
||||||
|
} else {
|
||||||
|
loginStateMu.Unlock()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loginStateMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
||||||
|
loginStateMu.Lock()
|
||||||
|
loginAttempts[userID] = time.Now()
|
||||||
|
loginStateMu.Unlock()
|
||||||
|
|
||||||
|
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// On success, clear attempts
|
||||||
|
loginStateMu.Lock()
|
||||||
|
delete(loginAttempts, userID)
|
||||||
|
loginStateMu.Unlock()
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
_, err = db.DB.Exec(ctx, `UPDATE users SET last_login_at = NOW() WHERE id = $1`, userID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Failed to update last_login_at:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT
|
||||||
|
tokenString, err := auth.GenerateToken(userID, role)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(auth.AuthResponse{Token: tokenString})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/refresh-token (requires auth middleware)
|
||||||
|
func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, _ := mw.GetUserID(r.Context())
|
||||||
|
role, _ := mw.GetUserRole(r.Context())
|
||||||
|
|
||||||
|
// Verify user still exists and role hasn't changed
|
||||||
|
var currentRole string
|
||||||
|
err := db.DB.QueryRow(r.Context(), `
|
||||||
|
SELECT account_role FROM users WHERE id = $1
|
||||||
|
`, userID).Scan(¤tRole)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "user not found", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If role changed, force re-login
|
||||||
|
if currentRole != role {
|
||||||
|
http.Error(w, "role changed, please log in again", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new token
|
||||||
|
newToken, err := auth.GenerateToken(userID, currentRole)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "could not generate token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(auth.AuthResponse{Token: newToken})
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package auth
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DELETE /api/user/account
|
||||||
|
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// TODO: Delete user's data
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/mw"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoyaltyResponse struct {
|
||||||
|
Stamps int `json:"stamps"`
|
||||||
|
ReferralCode string `json:"referralCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/user/loyalty
|
||||||
|
func GetLoyaltyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, _ := mw.GetUserID(r.Context())
|
||||||
|
|
||||||
|
var loyalty LoyaltyResponse
|
||||||
|
err := db.DB.QueryRow(r.Context(), `
|
||||||
|
SELECT loyalty_stamps, referral_code
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`, userID).Scan(&loyalty.Stamps, &loyalty.ReferralCode)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to get loyalty info", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(loyalty)
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/handlers/auth"
|
||||||
|
"crussell/mw"
|
||||||
|
)
|
||||||
|
|
||||||
|
var titleCaser = cases.Title(language.English)
|
||||||
|
|
||||||
|
type UserProfile struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
FirstName string `json:"firstName"`
|
||||||
|
LastName string `json:"lastName"`
|
||||||
|
Phone *string `json:"phone,omitempty"`
|
||||||
|
DateOfBirth *string `json:"dateOfBirth,omitempty"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
LoyaltyStamps int `json:"loyaltyStamps"`
|
||||||
|
ReferralCode string `json:"referralCode"`
|
||||||
|
ProfilePicURL *string `json:"profilePicUrl,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateProfileRequest struct {
|
||||||
|
FirstName string `json:"firstName"`
|
||||||
|
LastName string `json:"lastName"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/user/profile
|
||||||
|
func GetProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := mw.GetUserID(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user UserProfile
|
||||||
|
err := db.DB.QueryRow(r.Context(), `
|
||||||
|
SELECT
|
||||||
|
id, email, n_first_name, n_last_name, phone,
|
||||||
|
date_of_birth::text, account_role, loyalty_stamps,
|
||||||
|
referral_code, profile_pic_url
|
||||||
|
FROM users
|
||||||
|
WHERE id = $1
|
||||||
|
`, userID).Scan(
|
||||||
|
&user.ID, &user.Email, &user.FirstName, &user.LastName,
|
||||||
|
&user.Phone, &user.DateOfBirth, &user.Role,
|
||||||
|
&user.LoyaltyStamps, &user.ReferralCode, &user.ProfilePicURL,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "user not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/user/profile
|
||||||
|
// updateCardDAV updates an existing contact in SabreDAV using user ID
|
||||||
|
func updateCardDAV(userID, firstName, lastName, email, phone, dob string) error {
|
||||||
|
// Use user ID as filename - consistent with registration
|
||||||
|
filename := fmt.Sprintf("%s.vcf", userID)
|
||||||
|
url := fmt.Sprintf("http://nginx/dav/addressbooks/principals/default/default/%s", filename)
|
||||||
|
|
||||||
|
// Create vCard with user ID as UID (no need to fetch existing)
|
||||||
|
timestamp := time.Now().UTC().Format("20060102T150405Z")
|
||||||
|
uid := fmt.Sprintf("%s@example.com", userID)
|
||||||
|
|
||||||
|
vcard := fmt.Sprintf(`BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
UID:%s
|
||||||
|
FN:%s %s
|
||||||
|
N:%s;%s;;;
|
||||||
|
EMAIL;TYPE=INTERNET:%s
|
||||||
|
TEL;TYPE=CELL:%s
|
||||||
|
BDAY:%s
|
||||||
|
REV:%s
|
||||||
|
END:VCARD`, uid, firstName, lastName, lastName, firstName, email, phone, dob, timestamp)
|
||||||
|
|
||||||
|
// PUT updated vCard
|
||||||
|
req, err := http.NewRequest("PUT", url, bytes.NewBufferString(vcard))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "text/vcard; charset=utf-8")
|
||||||
|
req.SetBasicAuth("admin", "admin")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update CardDAV: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("CardDAV returned status: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/user/profile
|
||||||
|
func UpdateProfileHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, _ := mw.GetUserID(r.Context())
|
||||||
|
|
||||||
|
var req UpdateProfileRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "invalid request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize input
|
||||||
|
req.FirstName = strings.TrimSpace(req.FirstName)
|
||||||
|
req.LastName = strings.TrimSpace(req.LastName)
|
||||||
|
req.Phone = strings.TrimSpace(req.Phone)
|
||||||
|
|
||||||
|
// Required fields
|
||||||
|
if req.FirstName == "" || req.LastName == "" || req.Phone == "" {
|
||||||
|
http.Error(w, "first name, last name and phone are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate names (unicode letters, spaces, hyphen, apostrophe, dot)
|
||||||
|
nameRegex := regexp.MustCompile(`^[\p{L}\p{M}\s\-'\.]+$`)
|
||||||
|
|
||||||
|
if !nameRegex.MatchString(req.FirstName) || !nameRegex.MatchString(req.LastName) {
|
||||||
|
http.Error(w, "invalid characters in name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate lengths
|
||||||
|
if len(req.FirstName) < 1 || len(req.FirstName) > 50 {
|
||||||
|
http.Error(w, "first name must be 1-50 characters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.LastName) < 1 || len(req.LastName) > 50 {
|
||||||
|
http.Error(w, "last name must be 1-50 characters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize phone (strip spaces, hyphens, brackets)
|
||||||
|
req.Phone = strings.Map(func(r rune) rune {
|
||||||
|
if (r >= '0' && r <= '9') || r == '+' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}, req.Phone)
|
||||||
|
|
||||||
|
// Validate UK phone number
|
||||||
|
phone, err := auth.ValidateUKPhoneNumber(req.Phone)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid phone number format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Phone = strings.TrimSpace(phone)
|
||||||
|
|
||||||
|
// Title case names
|
||||||
|
req.FirstName = titleCaser.String(strings.ToLower(req.FirstName))
|
||||||
|
req.LastName = titleCaser.String(strings.ToLower(req.LastName))
|
||||||
|
|
||||||
|
// Fetch user's email and DOB for CardDAV update
|
||||||
|
var email string
|
||||||
|
var dob time.Time
|
||||||
|
err = db.DB.QueryRow(r.Context(), `
|
||||||
|
SELECT email, date_of_birth FROM users WHERE id = $1
|
||||||
|
`, userID).Scan(&email, &dob)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "failed to fetch user data", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DB
|
||||||
|
_, err = db.DB.Exec(r.Context(), `
|
||||||
|
UPDATE users
|
||||||
|
SET n_first_name = $1, n_last_name = $2, phone = $3, updated_at = NOW()
|
||||||
|
WHERE id = $4
|
||||||
|
`, req.FirstName, req.LastName, req.Phone, userID)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "update failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update CardDAV (non-blocking)
|
||||||
|
go func() {
|
||||||
|
dobStr := dob.Format("2006-01-02")
|
||||||
|
if err := updateCardDAV(userID, req.FirstName, req.LastName, email, req.Phone, dobStr); err != nil {
|
||||||
|
fmt.Printf("Warning: Failed to update CardDAV contact for user %s: %v\n", userID, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//go:build dev
|
||||||
|
// +build dev
|
||||||
|
|
||||||
|
package dav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Service *BaseService
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if err := connect(); err != nil {
|
||||||
|
log.Fatalf("failed to initialize dev service: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect() error {
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"postgres://%s:%s@localhost:5432/%s?sslmode=disable",
|
||||||
|
getEnv("POSTGRES_USER"),
|
||||||
|
getEnv("POSTGRES_PASSWORD"),
|
||||||
|
getEnv("POSTGRES_DB"),
|
||||||
|
)
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
Service = newBaseService(pool)
|
||||||
|
return testDB(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDB(pool *pgxpool.Pool) error {
|
||||||
|
var n int
|
||||||
|
err := pool.QueryRow(context.Background(), "SELECT 1").Scan(&n)
|
||||||
|
if err != nil || n != 1 {
|
||||||
|
return fmt.Errorf("db test failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
log.Fatalf("FATAL: environment variable %s not set", key)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
//go:build !dev
|
||||||
|
// +build !dev
|
||||||
|
|
||||||
|
package dav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Service *BaseService
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if err := connect(); err != nil {
|
||||||
|
log.Fatalf("failed to initialize prod service: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect() error {
|
||||||
|
dsn := fmt.Sprintf(
|
||||||
|
"postgres://%s:%s@%s:5432/%s",
|
||||||
|
getEnv("POSTGRES_USER"),
|
||||||
|
getEnv("POSTGRES_PASSWORD"),
|
||||||
|
getEnv("POSTGRES_HOST"),
|
||||||
|
getEnv("POSTGRES_DB"),
|
||||||
|
)
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
Service = newBaseService(pool)
|
||||||
|
return testDB(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDB(pool *pgxpool.Pool) error {
|
||||||
|
var n int
|
||||||
|
err := pool.QueryRow(context.Background(), "SELECT 1").Scan(&n)
|
||||||
|
if err != nil || n != 1 {
|
||||||
|
return fmt.Errorf("db test failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
log.Fatalf("FATAL: environment variable %s not set", key)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package dav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BaseService holds shared DB connection
|
||||||
|
type BaseService struct {
|
||||||
|
db *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBaseService returns a new BaseService instance
|
||||||
|
func newBaseService(db *pgxpool.Pool) *BaseService {
|
||||||
|
return &BaseService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Calendar Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func (s *BaseService) ListEventsForMonth(year int, month time.Month) ([]CalendarEvent, error) {
|
||||||
|
start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
end := start.AddDate(0, 1, 0).Add(-time.Second)
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
|
||||||
|
firstoccurence, lastoccurence, uid
|
||||||
|
FROM dav_calendarobjects
|
||||||
|
WHERE firstoccurence >= $1 AND firstoccurence <= $2
|
||||||
|
ORDER BY firstoccurence
|
||||||
|
`
|
||||||
|
return s.queryEventsWithContacts(query, start.Unix(), end.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BaseService) ListEventsBetween(start, end time.Time) ([]CalendarEvent, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
|
||||||
|
firstoccurence, lastoccurence, uid
|
||||||
|
FROM dav_calendarobjects
|
||||||
|
WHERE firstoccurence >= $1 AND firstoccurence <= $2
|
||||||
|
ORDER BY firstoccurence
|
||||||
|
`
|
||||||
|
return s.queryEventsWithContacts(query, start.Unix(), end.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BaseService) ListEventsTomorrow() ([]CalendarEvent, error) {
|
||||||
|
now := time.Now()
|
||||||
|
tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
||||||
|
dayAfter := tomorrow.Add(24 * time.Hour)
|
||||||
|
return s.ListEventsBetween(tomorrow, dayAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BaseService) ListEventsThisWeek() ([]CalendarEvent, error) {
|
||||||
|
now := time.Now()
|
||||||
|
weekday := int(now.Weekday())
|
||||||
|
if weekday == 0 { // Sunday
|
||||||
|
weekday = 7
|
||||||
|
}
|
||||||
|
monday := now.AddDate(0, 0, -weekday+1)
|
||||||
|
monday = time.Date(monday.Year(), monday.Month(), monday.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
sunday := monday.AddDate(0, 0, 7)
|
||||||
|
return s.ListEventsBetween(monday, sunday)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Contact Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func (s *BaseService) GetContactByURI(addressBookID int, uri string) (*Contact, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, addressbookid, uri, carddata, lastmodified, etag, size
|
||||||
|
FROM dav_cards
|
||||||
|
WHERE addressbookid = $1 AND uri = $2
|
||||||
|
`
|
||||||
|
var c Contact
|
||||||
|
err := s.db.QueryRow(context.Background(), query, addressBookID, uri).Scan(
|
||||||
|
&c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("contact not found: %w", err)
|
||||||
|
}
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BaseService) ListAllContacts() ([]Contact, error) {
|
||||||
|
query := `SELECT id, addressbookid, uri, carddata, lastmodified, etag, size FROM dav_cards ORDER BY lastmodified DESC`
|
||||||
|
rows, err := s.db.Query(context.Background(), query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var contacts []Contact
|
||||||
|
for rows.Next() {
|
||||||
|
var c Contact
|
||||||
|
if err := rows.Scan(&c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
contacts = append(contacts, c)
|
||||||
|
}
|
||||||
|
return contacts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *BaseService) ListRecentContacts(days int) ([]Contact, error) {
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -days).Unix()
|
||||||
|
query := `SELECT id, addressbookid, uri, carddata, lastmodified, etag, size FROM dav_cards WHERE lastmodified >= $1 ORDER BY lastmodified DESC`
|
||||||
|
rows, err := s.db.Query(context.Background(), query, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var contacts []Contact
|
||||||
|
for rows.Next() {
|
||||||
|
var c Contact
|
||||||
|
if err := rows.Scan(&c.ID, &c.AddressBookID, &c.URI, &c.CardData, &c.LastModified, &c.Etag, &c.Size); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
contacts = append(contacts, c)
|
||||||
|
}
|
||||||
|
return contacts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
func (s *BaseService) queryEventsWithContacts(query string, args ...interface{}) ([]CalendarEvent, error) {
|
||||||
|
rows, err := s.db.Query(context.Background(), query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var events []CalendarEvent
|
||||||
|
for rows.Next() {
|
||||||
|
var e CalendarEvent
|
||||||
|
if err := rows.Scan(
|
||||||
|
&e.ID, &e.CalendarID, &e.URI, &e.CalendarData, &e.LastModified, &e.Etag, &e.Size,
|
||||||
|
&e.ComponentType, &e.FirstOccurence, &e.LastOccurence, &e.UID,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.ContactURIs = extractContactURIsFromICalendar(e.CalendarData)
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractContactURIsFromICalendar(icalData string) []string {
|
||||||
|
var uris []string
|
||||||
|
for _, line := range strings.Split(icalData, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "ATTENDEE") {
|
||||||
|
parts := strings.Split(line, ":")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
uris = append(uris, strings.TrimSpace(parts[len(parts)-1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uris
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateContact adds a new contact to an address book
|
||||||
|
func (s *BaseService) CreateContact(addressBookID int, userID string, input ContactInput) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
uri := fmt.Sprintf("%s.vcf", userID)
|
||||||
|
cardData := GenerateVCard(input)
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO dav_cards (addressbookid, uri, carddata, lastmodified, etag, size)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
`
|
||||||
|
_, err := s.db.Exec(context.Background(), query,
|
||||||
|
addressBookID,
|
||||||
|
uri,
|
||||||
|
cardData,
|
||||||
|
now,
|
||||||
|
fmt.Sprintf("%d", now),
|
||||||
|
len(cardData),
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateContact updates an existing contact by URI
|
||||||
|
func (s *BaseService) UpdateContact(addressBookID int, uri string, input ContactInput) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
cardData := GenerateVCard(input)
|
||||||
|
query := `
|
||||||
|
UPDATE dav_cards
|
||||||
|
SET carddata = $1, lastmodified = $2, etag = $3, size = $4
|
||||||
|
WHERE addressbookid = $5 AND uri = $6
|
||||||
|
`
|
||||||
|
_, err := s.db.Exec(context.Background(), query, cardData, now, fmt.Sprintf("%d", now), len(cardData), addressBookID, uri)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteContact deletes a contact by URI
|
||||||
|
func (s *BaseService) DeleteContact(addressBookID int, uri string) error {
|
||||||
|
query := `DELETE FROM dav_cards WHERE addressbookid = $1 AND uri = $2`
|
||||||
|
_, err := s.db.Exec(context.Background(), query, addressBookID, uri)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateEvent adds a new event to a calendar
|
||||||
|
func (s *BaseService) CreateEvent(calendarID int, input EventInput) error {
|
||||||
|
uid := fmt.Sprintf("%d@example.com", time.Now().UnixNano())
|
||||||
|
now := time.Now().Unix()
|
||||||
|
calendarData := GenerateICalEvent(input)
|
||||||
|
|
||||||
|
query := `
|
||||||
|
INSERT INTO dav_calendarobjects
|
||||||
|
(calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
|
||||||
|
firstoccurence, lastoccurence, uid)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, 'VEVENT', $7, $8, $9)
|
||||||
|
`
|
||||||
|
_, err := s.db.Exec(context.Background(), query,
|
||||||
|
calendarID,
|
||||||
|
uid+".ics",
|
||||||
|
calendarData,
|
||||||
|
now,
|
||||||
|
fmt.Sprintf("%d", now),
|
||||||
|
len(calendarData),
|
||||||
|
input.Start.Unix(),
|
||||||
|
input.End.Unix(),
|
||||||
|
uid,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateEvent updates an existing calendar event by UID
|
||||||
|
func (s *BaseService) UpdateEvent(calendarID int, uid string, input EventInput) error {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
calendarData := GenerateICalEvent(input)
|
||||||
|
|
||||||
|
query := `
|
||||||
|
UPDATE dav_calendarobjects
|
||||||
|
SET calendardata = $1, lastmodified = $2, etag = $3, size = $4,
|
||||||
|
firstoccurence = $5, lastoccurence = $6
|
||||||
|
WHERE calendarid = $7 AND uid = $8
|
||||||
|
`
|
||||||
|
_, err := s.db.Exec(context.Background(), query,
|
||||||
|
calendarData, now, fmt.Sprintf("%d", now), len(calendarData),
|
||||||
|
input.Start.Unix(), input.End.Unix(),
|
||||||
|
calendarID, uid,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEvent deletes an event by UID
|
||||||
|
func (s *BaseService) DeleteEvent(calendarID int, uid string) error {
|
||||||
|
query := `DELETE FROM dav_calendarobjects WHERE calendarid = $1 AND uid = $2`
|
||||||
|
_, err := s.db.Exec(context.Background(), query, calendarID, uid)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEventsForContactSQL returns all calendar events where the given contact URI is an attendee (SQL optimized)
|
||||||
|
func (s *BaseService) ListEventsForContact(contactURI string) ([]CalendarEvent, error) {
|
||||||
|
// Use pattern matching to find events containing the contact URI in ATTENDEE lines
|
||||||
|
likePattern := "%" + contactURI + "%"
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT id, calendarid, uri, calendardata, lastmodified, etag, size, componenttype,
|
||||||
|
firstoccurence, lastoccurence, uid
|
||||||
|
FROM dav_calendarobjects
|
||||||
|
WHERE calendardata LIKE $1
|
||||||
|
ORDER BY firstoccurence
|
||||||
|
`
|
||||||
|
|
||||||
|
return s.queryEventsWithContacts(query, likePattern)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package dav
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Calendar Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type Calendar struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
PrincipalURI string `json:"principaluri"`
|
||||||
|
DisplayName string `json:"displayname"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
CalendarOrder int `json:"calendarorder"`
|
||||||
|
CalendarColor string `json:"calendarcolor"`
|
||||||
|
Components string `json:"components"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CalendarEvent struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
CalendarID int `json:"calendarid"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
CalendarData string `json:"calendardata"`
|
||||||
|
LastModified int64 `json:"lastmodified"`
|
||||||
|
Etag string `json:"etag"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
ComponentType string `json:"componenttype"`
|
||||||
|
FirstOccurence int64 `json:"firstoccurence"`
|
||||||
|
LastOccurence int64 `json:"lastoccurence"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
ContactURIs []string `json:"contact_uris"` // Extracted from ATTENDEE fields
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventInput struct {
|
||||||
|
Summary string
|
||||||
|
Description string
|
||||||
|
Location string
|
||||||
|
Start time.Time
|
||||||
|
End time.Time
|
||||||
|
AllDay bool
|
||||||
|
ContactURIs []string // URIs of contacts to attach as attendees
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CardDAV Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
type AddressBook struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
PrincipalURI string `json:"principaluri"`
|
||||||
|
DisplayName string `json:"displayname"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
SyncToken int `json:"synctoken"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Contact struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
AddressBookID int `json:"addressbookid"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
CardData string `json:"carddata"`
|
||||||
|
LastModified int64 `json:"lastmodified"`
|
||||||
|
Etag string `json:"etag"`
|
||||||
|
Size int `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContactInput struct {
|
||||||
|
UserID string
|
||||||
|
FirstName string
|
||||||
|
LastName string
|
||||||
|
Email string
|
||||||
|
Phone string
|
||||||
|
DOB string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// GenerateICalEvent creates iCalendar format for UK timezone
|
||||||
|
func GenerateICalEvent(input EventInput) string {
|
||||||
|
uid := fmt.Sprintf("%d@example.com", time.Now().UnixNano())
|
||||||
|
dtstamp := time.Now().UTC().Format("20060102T150405Z")
|
||||||
|
|
||||||
|
var dtstart, dtend string
|
||||||
|
if input.AllDay {
|
||||||
|
dtstart = fmt.Sprintf("DTSTART;VALUE=DATE:%s", input.Start.Format("20060102"))
|
||||||
|
dtend = fmt.Sprintf("DTEND;VALUE=DATE:%s", input.End.Format("20060102"))
|
||||||
|
} else {
|
||||||
|
dtstart = fmt.Sprintf("DTSTART;TZID=Europe/London:%s", input.Start.Format("20060102T150405"))
|
||||||
|
dtend = fmt.Sprintf("DTEND;TZID=Europe/London:%s", input.End.Format("20060102T150405"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build attendees section
|
||||||
|
attendees := ""
|
||||||
|
for _, contactURI := range input.ContactURIs {
|
||||||
|
attendees += fmt.Sprintf("ATTENDEE;CN=%s:%s\n", contactURI, contactURI)
|
||||||
|
}
|
||||||
|
|
||||||
|
ical := fmt.Sprintf(`BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Your App//EN
|
||||||
|
CALSCALE:GREGORIAN
|
||||||
|
BEGIN:VTIMEZONE
|
||||||
|
TZID:Europe/London
|
||||||
|
BEGIN:DAYLIGHT
|
||||||
|
TZOFFSETFROM:+0000
|
||||||
|
TZOFFSETTO:+0100
|
||||||
|
TZNAME:BST
|
||||||
|
DTSTART:19700329T010000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
|
||||||
|
END:DAYLIGHT
|
||||||
|
BEGIN:STANDARD
|
||||||
|
TZOFFSETFROM:+0100
|
||||||
|
TZOFFSETTO:+0000
|
||||||
|
TZNAME:GMT
|
||||||
|
DTSTART:19701025T020000
|
||||||
|
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
|
||||||
|
END:STANDARD
|
||||||
|
END:VTIMEZONE
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:%s
|
||||||
|
DTSTAMP:%s
|
||||||
|
%s
|
||||||
|
%s
|
||||||
|
SUMMARY:%s
|
||||||
|
DESCRIPTION:%s
|
||||||
|
LOCATION:%s
|
||||||
|
%sSEQUENCE:0
|
||||||
|
STATUS:CONFIRMED
|
||||||
|
TRANSP:OPAQUE
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`, uid, dtstamp, dtstart, dtend,
|
||||||
|
escapeICalText(input.Summary),
|
||||||
|
escapeICalText(input.Description),
|
||||||
|
escapeICalText(input.Location),
|
||||||
|
attendees)
|
||||||
|
|
||||||
|
return ical
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateVCard creates vCard format (version 3.0)
|
||||||
|
func GenerateVCard(input ContactInput) string {
|
||||||
|
vcard := fmt.Sprintf(`BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
UID:%s
|
||||||
|
FN:%s %s
|
||||||
|
N:%s;%s;;;
|
||||||
|
EMAIL;TYPE=INTERNET:%s
|
||||||
|
TEL;TYPE=CELL:%s
|
||||||
|
BDAY:%s
|
||||||
|
REV:%s
|
||||||
|
END:VCARD`,
|
||||||
|
input.UserID,
|
||||||
|
input.FirstName, input.LastName,
|
||||||
|
input.LastName, input.FirstName,
|
||||||
|
input.Email,
|
||||||
|
input.Phone,
|
||||||
|
input.DOB,
|
||||||
|
time.Now().UTC().Format("20060102T150405Z"))
|
||||||
|
|
||||||
|
return vcard
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeICalText(text string) string {
|
||||||
|
text = replaceAll(text, "\\", "\\\\")
|
||||||
|
text = replaceAll(text, "\n", "\\n")
|
||||||
|
text = replaceAll(text, ",", "\\,")
|
||||||
|
text = replaceAll(text, ";", "\\;")
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceAll(s, old, new string) string {
|
||||||
|
result := ""
|
||||||
|
for _, char := range s {
|
||||||
|
if string(char) == old {
|
||||||
|
result += new
|
||||||
|
} else {
|
||||||
|
result += string(char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crussell/auth"
|
||||||
|
"crussell/internal/dav"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
"crussell/db"
|
||||||
|
"crussell/mw"
|
||||||
|
|
||||||
|
authHandlers "crussell/handlers/auth"
|
||||||
|
userHandlers "crussell/handlers/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// 1. Read the environment variable
|
||||||
|
jwtSecret := os.Getenv("JWT_SECRET_KEY")
|
||||||
|
|
||||||
|
// 2. Add a check to ensure the secret is set
|
||||||
|
if jwtSecret == "" {
|
||||||
|
log.Fatal("FATAL: JWT_SECRET_KEY environment variable not set. Application cannot start.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Use the environment variable for initialization
|
||||||
|
auth.InitJWT(jwtSecret)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initDB() {
|
||||||
|
if err := db.Connect(); err != nil {
|
||||||
|
log.Fatal("Failed to connect to DB:", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Connected to DB successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func initDav() {
|
||||||
|
if dav.Service == nil {
|
||||||
|
log.Fatal("Failed to initialize DAV service")
|
||||||
|
}
|
||||||
|
fmt.Println("DAV Service connected successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
initDB()
|
||||||
|
initDav()
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(middleware.RequestID) // Add X-Request-ID header
|
||||||
|
r.Use(middleware.RealIP) // Get real IP from headers
|
||||||
|
r.Use(middleware.Logger) // Basic logging
|
||||||
|
r.Use(middleware.Recoverer) // Panic recovery
|
||||||
|
r.Use(middleware.Timeout(15 * time.Second)) // Request timeout
|
||||||
|
|
||||||
|
r.Use(func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Public auth routes
|
||||||
|
r.Post("/api/register", authHandlers.RegisterHandler)
|
||||||
|
r.Post("/api/login", authHandlers.LoginHandler)
|
||||||
|
|
||||||
|
// Protected routes - any authenticated user
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
r.Post("/api/refresh-token", authHandlers.RefreshTokenHandler)
|
||||||
|
|
||||||
|
// User profile
|
||||||
|
r.Get("/api/user/profile", userHandlers.GetProfileHandler)
|
||||||
|
r.Put("/api/user/profile", userHandlers.UpdateProfileHandler)
|
||||||
|
r.Delete("/api/user/account", userHandlers.DeleteAccountHandler)
|
||||||
|
|
||||||
|
// Loyalty
|
||||||
|
r.Get("/api/user/loyalty", userHandlers.GetLoyaltyHandler)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Protected routes - verified users only
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Use(mw.RequireVerified)
|
||||||
|
|
||||||
|
// Add booking routes, etc.
|
||||||
|
})
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(mw.RequireAuth)
|
||||||
|
r.Use(mw.RequireAdmin)
|
||||||
|
|
||||||
|
// Add admin routes
|
||||||
|
})
|
||||||
|
|
||||||
|
fmt.Println("Server is listening on :8080")
|
||||||
|
http.ListenAndServe(":8080", r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package mw
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"crussell/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
UserIDKey contextKey = "user_id"
|
||||||
|
UserRoleKey contextKey = "user_role"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequireAuth middleware - validates JWT and adds user info to context
|
||||||
|
func RequireAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
http.Error(w, "missing or invalid authorization header", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
userID, role, err := auth.VerifyToken(tokenString, r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user info to context
|
||||||
|
ctx := context.WithValue(r.Context(), UserIDKey, userID)
|
||||||
|
ctx = context.WithValue(ctx, UserRoleKey, role)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireRole middleware - checks if user has required role(s)
|
||||||
|
func RequireRole(allowedRoles ...string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
role, ok := r.Context().Value(UserRoleKey).(string)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has one of the allowed roles
|
||||||
|
hasRole := false
|
||||||
|
for _, allowedRole := range allowedRoles {
|
||||||
|
if role == allowedRole {
|
||||||
|
hasRole = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasRole {
|
||||||
|
http.Error(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireVerified middleware - only allows verified_email and admin
|
||||||
|
func RequireVerified(next http.Handler) http.Handler {
|
||||||
|
return RequireRole("verified_email", "admin")(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAdmin middleware - only allows admin
|
||||||
|
func RequireAdmin(next http.Handler) http.Handler {
|
||||||
|
return RequireRole("admin")(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions to get user info from context
|
||||||
|
func GetUserID(ctx context.Context) (string, bool) {
|
||||||
|
userID, ok := ctx.Value(UserIDKey).(string)
|
||||||
|
return userID, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUserRole(ctx context.Context) (string, bool) {
|
||||||
|
role, ok := ctx.Value(UserRoleKey).(string)
|
||||||
|
return role, ok
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
container_name: postgres
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
ports:
|
||||||
|
- "5432:5432" # locally, do not push to prod
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./init-scripts/init-script.sql:/docker-entrypoint-initdb.d/init-script.sql:ro
|
||||||
|
networks:
|
||||||
|
- appnet
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: backend
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
|
volumes:
|
||||||
|
- ./backend/bin:/app/bin # Mount your compiled binary
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
networks:
|
||||||
|
- appnet
|
||||||
|
|
||||||
|
sabredav:
|
||||||
|
image: php:8.2-fpm
|
||||||
|
container_name: sabredav
|
||||||
|
restart: always
|
||||||
|
working_dir: /var/www/dav
|
||||||
|
environment:
|
||||||
|
POSTGRES_HOST: postgres
|
||||||
|
POSTGRES_PORT: 5432
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- ./sabredav:/var/www/dav
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
networks:
|
||||||
|
- appnet
|
||||||
|
command: >
|
||||||
|
bash -c " apt-get update && apt-get install -y git unzip libzip-dev libpq-dev && docker-php-ext-install pdo pdo_pgsql zip && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer && if [ ! -f /var/www/dav/vendor/autoload.php ]; then
|
||||||
|
cd /var/www/dav && composer install --no-dev --optimize-autoloader;
|
||||||
|
fi && php-fpm "
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:stable
|
||||||
|
container_name: nginx
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./nginx/conf.d:/etc/nginx/conf.d
|
||||||
|
- ./nginx/certs:/etc/nginx/certs
|
||||||
|
- ./frontend/build:/usr/share/nginx/html # Serve frontend
|
||||||
|
- ./sabredav:/var/www/dav
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- sabredav
|
||||||
|
networks:
|
||||||
|
- appnet
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
|
||||||
|
|
||||||
|
networks:
|
||||||
|
appnet:
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tailwindStylesheet": "./src/app.css"
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
|
||||||
|
# create a new project in my-app
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
|
"tailwind": {
|
||||||
|
"css": "src/app.css",
|
||||||
|
"baseColor": "slate"
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "$lib/components",
|
||||||
|
"utils": "$lib/utils",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
|
},
|
||||||
|
"typescript": true,
|
||||||
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
|
import globals from 'globals';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser, ...globals.node }
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||||
|
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||||
|
'no-undef': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "crussell",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "prettier --check . && eslint ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/compat": "^1.2.5",
|
||||||
|
"@eslint/js": "^9.22.0",
|
||||||
|
"@internationalized/date": "^3.9.0",
|
||||||
|
"@lucide/svelte": "^0.515.0",
|
||||||
|
"@sveltejs/adapter-auto": "^6.0.0",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.9",
|
||||||
|
"@sveltejs/kit": "^2.22.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/swiper": "^5.4.3",
|
||||||
|
"bits-ui": "^2.10.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"eslint": "^9.22.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-svelte": "^3.0.0",
|
||||||
|
"formsnap": "^2.0.1",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"mode-watcher": "^1.1.0",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"svelte-sonner": "^1.0.5",
|
||||||
|
"sveltekit-superforms": "^2.27.1",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwind-variants": "^1.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"tw-animate-css": "^1.3.8",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"typescript-eslint": "^8.20.0",
|
||||||
|
"vite": "^7.0.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@zxcvbn-ts/core": "^3.0.4",
|
||||||
|
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||||
|
"@zxcvbn-ts/language-en": "^3.0.2",
|
||||||
|
"swiper": "^10.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--primary: oklch(0.208 0.042 265.755);
|
||||||
|
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--secondary: oklch(0.968 0.007 247.896);
|
||||||
|
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--muted: oklch(0.968 0.007 247.896);
|
||||||
|
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||||
|
--accent: oklch(0.968 0.007 247.896);
|
||||||
|
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.929 0.013 255.508);
|
||||||
|
--input: oklch(0.929 0.013 255.508);
|
||||||
|
--ring: oklch(0.704 0.04 256.788);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||||
|
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||||
|
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.129 0.042 264.695);
|
||||||
|
--foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--card: oklch(0.208 0.042 265.755);
|
||||||
|
--card-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--popover: oklch(0.208 0.042 265.755);
|
||||||
|
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--primary: oklch(0.929 0.013 255.508);
|
||||||
|
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--secondary: oklch(0.279 0.041 260.031);
|
||||||
|
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--muted: oklch(0.279 0.041 260.031);
|
||||||
|
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||||
|
--accent: oklch(0.279 0.041 260.031);
|
||||||
|
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.551 0.027 264.364);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||||
|
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add this to your app.css or global styles */
|
||||||
|
.calendar-container {
|
||||||
|
z-index: 49 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Or target the specific calendar component if needed */
|
||||||
|
[data-calendar] {
|
||||||
|
z-index: 49 !important;
|
||||||
|
}
|
||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { };
|
||||||
|
declare module 'swiper/svelte';
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import Calendar from '$lib/components/ui/calendar/calendar.svelte';
|
||||||
|
import { CalendarDate, getLocalTimeZone } from '@internationalized/date';
|
||||||
|
|
||||||
|
let value = $state<CalendarDate | undefined>(new CalendarDate(2025, 6, 12));
|
||||||
|
let selectedTime = $state<string | null>('10:00');
|
||||||
|
|
||||||
|
const bookedDates = Array.from({ length: 3 }, (_, i) => new CalendarDate(2025, 6, 17 + i));
|
||||||
|
const timeSlots = Array.from({ length: 37 }, (_, i) => {
|
||||||
|
const totalMinutes = i * 15;
|
||||||
|
const hour = Math.floor(totalMinutes / 60) + 9;
|
||||||
|
const minute = totalMinutes % 60;
|
||||||
|
return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card.Root class="gap-0 p-0">
|
||||||
|
<Card.Content class="relative p-0 md:pr-48">
|
||||||
|
<div class="p-6">
|
||||||
|
<Calendar
|
||||||
|
type="single"
|
||||||
|
bind:value
|
||||||
|
isDateUnavailable={(date) => bookedDates.some((d) => d.compare(date) === 0)}
|
||||||
|
class="bg-transparent p-0 [--cell-size:--spacing(10)] data-unavailable:line-through data-unavailable:opacity-100 md:[--cell-size:--spacing(12)] [&_[data-outside-month]]:hidden"
|
||||||
|
weekdayFormat="short"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="no-scrollbar inset-y-0 right-0 flex max-h-72 w-full scroll-pb-6 flex-col gap-4 overflow-y-auto border-t p-6 md:absolute md:max-h-none md:w-48 md:border-t-0 md:border-l"
|
||||||
|
>
|
||||||
|
<div class="grid gap-2">
|
||||||
|
{#each timeSlots as time (time)}
|
||||||
|
<Button
|
||||||
|
variant={selectedTime === time ? 'default' : 'outline'}
|
||||||
|
onclick={() => (selectedTime = time)}
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
{time}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
<Card.Footer class="flex flex-col gap-4 border-t px-6 !py-5 md:flex-row">
|
||||||
|
<div class="text-sm">
|
||||||
|
{#if value && selectedTime}
|
||||||
|
Your meeting is booked for
|
||||||
|
<span class="font-medium">
|
||||||
|
{value.toDate(getLocalTimeZone()).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short'
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
at <span class="font-medium">{selectedTime}</span>.
|
||||||
|
{:else}
|
||||||
|
Select a date and time for your meeting.
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
disabled={!value || !selectedTime}
|
||||||
|
class="w-full md:ml-auto md:w-auto"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
export let name: string = 'Chelsea Russell';
|
||||||
|
export let role: string = 'Owner / Beauty Specialist';
|
||||||
|
export let phone: string = '+44 8008135';
|
||||||
|
export let email: string = 'chelsea@emailaddress.com';
|
||||||
|
export let instagram: string = '@crussell';
|
||||||
|
export let address: string = 'Business Centre, Office Street, Work';
|
||||||
|
export let profileImage: string =
|
||||||
|
'https://images.icon-icons.com/5/PNG/256/MSN_messenger_user_156.png';
|
||||||
|
export let altText: string = 'Profile picture';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-sm">
|
||||||
|
<div class="rounded-lg border-3 border-gray-200 bg-white p-6">
|
||||||
|
<!-- Profile Picture -->
|
||||||
|
<div class="mb-4 flex justify-center">
|
||||||
|
<div class="h-24 w-24 overflow-hidden rounded-full border-4 border-white shadow-lg">
|
||||||
|
<img src={profileImage} alt={altText} class="h-full w-full object-cover" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Name and Role -->
|
||||||
|
<div class="mb-4 text-center">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">{name}</h2>
|
||||||
|
<p class="text-gray-500">{role}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Info -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="feather feather-map-pin"
|
||||||
|
><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle
|
||||||
|
cx="12"
|
||||||
|
cy="10"
|
||||||
|
r="3"
|
||||||
|
></circle></svg
|
||||||
|
>
|
||||||
|
<span>{address}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="feather feather-phone"
|
||||||
|
><path
|
||||||
|
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
|
||||||
|
></path></svg
|
||||||
|
>
|
||||||
|
<a href={`tel:${phone}`} class="text-primary hover:underline">{phone}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="feather feather-mail"
|
||||||
|
><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"
|
||||||
|
></path><polyline points="22,6 12,13 2,6"></polyline></svg
|
||||||
|
>
|
||||||
|
<a href={`mailto:${email}`} class="text-primary hover:underline">{email}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="feather feather-instagram"
|
||||||
|
><rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect><path
|
||||||
|
d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"
|
||||||
|
></path><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line></svg
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`https://instagram.com/${instagram}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-primary hover:underline">@{instagram}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { navigating, page } from '$app/stores';
|
||||||
|
import { authStore } from '$lib/stores/auth.svelte';
|
||||||
|
|
||||||
|
// Mobile menu state
|
||||||
|
let mobileMenuOpen: boolean = false;
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
mobileMenuOpen = !mobileMenuOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close mobile menu when navigation starts
|
||||||
|
$: if ($navigating) {
|
||||||
|
mobileMenuOpen = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
class="fixed left-0 top-0 z-50 w-full border-b border-gray-200"
|
||||||
|
class:frosty-nav={!mobileMenuOpen}
|
||||||
|
class:bg-background={mobileMenuOpen}
|
||||||
|
>
|
||||||
|
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex h-16 items-center justify-between">
|
||||||
|
<!-- Left: Social Icons -->
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<a
|
||||||
|
href="https://instagram.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="hover:text-primary text-gray-600"
|
||||||
|
aria-label="Instagram"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="feather feather-instagram"
|
||||||
|
><rect x="2" y="2" width="20" height="20" rx="5" ry="5"></rect><path
|
||||||
|
d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"
|
||||||
|
></path><line x1="17.5" y1="6.5" x2="17.51" y2="6.5"></line></svg
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
<!-- Add more social icons here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center: Links -->
|
||||||
|
<div class="hidden space-x-8 md:flex">
|
||||||
|
<a href="/" class="hover:text-primary font-medium text-gray-800">Home</a>
|
||||||
|
{#if !authStore?.isAuthenticated}
|
||||||
|
<a href="/prices" class="hover:text-primary font-medium text-gray-800">Price List</a>
|
||||||
|
{:else}
|
||||||
|
<a href="/book" class="hover:text-primary font-medium text-gray-800"
|
||||||
|
>Book your appointment</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<a href="/portfolio" class="hover:text-primary font-medium text-gray-800">Portfolio</a>
|
||||||
|
<a href="/contact" class="hover:text-primary font-medium text-gray-800">Contact</a>
|
||||||
|
{#if authStore?.isAuthenticated}
|
||||||
|
<a href="/account" class="hover:text-primary font-medium text-gray-800">My Account</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="hidden items-center md:flex">
|
||||||
|
<!-- if not logged in and not on login page -->
|
||||||
|
{#if !authStore?.isAuthenticated && $page.url.pathname !== '/login'}
|
||||||
|
<Button href="/login">Login</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile: Burger -->
|
||||||
|
<div class="flex items-center md:hidden">
|
||||||
|
<button on:click={toggleMenu} class="focus:outline-none" aria-label="Toggle menu">
|
||||||
|
<svg class="h-6 w-6 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Menu -->
|
||||||
|
{#if mobileMenuOpen}
|
||||||
|
<div class="bg-background border-b border-gray-200 md:hidden">
|
||||||
|
<div class="space-y-1 px-2 pb-3 pt-2">
|
||||||
|
<a href="/" class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
|
||||||
|
>Home</a
|
||||||
|
>
|
||||||
|
|
||||||
|
{#if !authStore?.isAuthenticated}
|
||||||
|
<a
|
||||||
|
href="/prices"
|
||||||
|
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
|
||||||
|
>Price list</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href="/book"
|
||||||
|
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
|
||||||
|
>Book your appointment</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/portfolio"
|
||||||
|
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800">Portfolio</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/contact"
|
||||||
|
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800">Contact</a
|
||||||
|
>
|
||||||
|
|
||||||
|
{#if !authStore?.isAuthenticated}
|
||||||
|
<Button href="/login" class="mt-2 w-full text-center">Login</Button>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
href="/Account"
|
||||||
|
class="text-primary block rounded px-3 py-2 text-center hover:text-gray-800"
|
||||||
|
>My Account</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.frosty-nav {
|
||||||
|
backdrop-filter: saturate(180%) blur(10px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Sample portfolio images - replace with your actual nail art images
|
||||||
|
const portfolioImages = [
|
||||||
|
{
|
||||||
|
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Hand%2C_fingers_-_back.jpg/1024px-Hand%2C_fingers_-_back.jpg',
|
||||||
|
alt: 'lorem ipsum'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9d/Palm%2C_fingers.jpg/1024px-Palm%2C_fingers.jpg',
|
||||||
|
alt: 'lorem ipsum'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/5/52/Hand_parts_-_en.svg/1920px-Hand_parts_-_en.svg.png',
|
||||||
|
alt: 'lorem ipsum'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Scheme_human_hand_bones-en.svg/1920px-Scheme_human_hand_bones-en.svg.png',
|
||||||
|
alt: 'lorem ipsum'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://upload.wikimedia.org/wikipedia/commons/a/a1/3D_Medical_Animation_Human_Wrist.jpg',
|
||||||
|
alt: 'lorem ipsum'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d1/Wrist_and_hand_deeper_palmar_dissection-en.svg/1920px-Wrist_and_hand_deeper_palmar_dissection-en.svg.png',
|
||||||
|
alt: 'lorem ipsum'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://upload.wikimedia.org/wikipedia/commons/3/38/Wrist_extensor_compartments_%28numbered%29.PNG',
|
||||||
|
alt: 'lorem ipsum'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Gray812and814.svg/1920px-Gray812and814.svg.png',
|
||||||
|
alt: 'lorem ipsum'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="w-full overflow-hidden py-16">
|
||||||
|
<div class="mx-auto max-w-6xl px-6">
|
||||||
|
<h2 class="mb-12 text-center text-2xl font-semibold">Our Portfolio</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative w-full overflow-hidden">
|
||||||
|
<div class="animate-scroll flex">
|
||||||
|
{#each [...portfolioImages, ...portfolioImages] as image}
|
||||||
|
<div class="group flex-shrink-0">
|
||||||
|
<img
|
||||||
|
src={image.src}
|
||||||
|
alt={image.alt}
|
||||||
|
class="h-80 w-64 object-cover saturate-100 transition-all duration-500 md:saturate-25 md:group-hover:saturate-100"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes scroll {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-scroll {
|
||||||
|
animation: scroll 40s linear infinite;
|
||||||
|
width: calc(256px * 16); /* 16 images total (8 duplicated) * 256px width */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* images scale on hover */
|
||||||
|
.group:hover img {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from '$lib/components/ui/label/index.js';
|
||||||
|
|
||||||
|
export let forId: string;
|
||||||
|
export let text: string;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Label for={forId}>
|
||||||
|
{text}
|
||||||
|
<span class="ml-1 font-bold" style="color: var(--chart-1)"> * </span>
|
||||||
|
</Label>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||||
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
|
|
||||||
|
export const buttonVariants = tv({
|
||||||
|
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
'bg-primary text-primary-foreground shadow-xs hover:bg-fuchsia-200 hover:text-foreground',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
||||||
|
outline:
|
||||||
|
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
|
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||||
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
|
icon: 'size-9'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||||
|
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||||
|
|
||||||
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
class: className,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
ref = $bindable(null),
|
||||||
|
href = undefined,
|
||||||
|
type = 'button',
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ButtonProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if href}
|
||||||
|
<a
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
href={disabled ? undefined : href}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
role={disabled ? 'link' : undefined}
|
||||||
|
tabindex={disabled ? -1 : undefined}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="button"
|
||||||
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
{type}
|
||||||
|
{disabled}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import Root, {
|
||||||
|
type ButtonProps,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
buttonVariants,
|
||||||
|
} from "./button.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
type ButtonProps as Props,
|
||||||
|
//
|
||||||
|
Root as Button,
|
||||||
|
buttonVariants,
|
||||||
|
type ButtonProps,
|
||||||
|
type ButtonSize,
|
||||||
|
type ButtonVariant,
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
import type Calendar from "./calendar.svelte";
|
||||||
|
import CalendarMonthSelect from "./calendar-month-select.svelte";
|
||||||
|
import CalendarYearSelect from "./calendar-year-select.svelte";
|
||||||
|
import { DateFormatter, getLocalTimeZone, type DateValue } from "@internationalized/date";
|
||||||
|
|
||||||
|
let {
|
||||||
|
captionLayout,
|
||||||
|
months,
|
||||||
|
monthFormat,
|
||||||
|
years,
|
||||||
|
yearFormat,
|
||||||
|
month,
|
||||||
|
locale,
|
||||||
|
placeholder = $bindable(),
|
||||||
|
monthIndex = 0,
|
||||||
|
}: {
|
||||||
|
captionLayout: ComponentProps<typeof Calendar>["captionLayout"];
|
||||||
|
months: ComponentProps<typeof CalendarMonthSelect>["months"];
|
||||||
|
monthFormat: ComponentProps<typeof CalendarMonthSelect>["monthFormat"];
|
||||||
|
years: ComponentProps<typeof CalendarYearSelect>["years"];
|
||||||
|
yearFormat: ComponentProps<typeof CalendarYearSelect>["yearFormat"];
|
||||||
|
month: DateValue;
|
||||||
|
placeholder: DateValue | undefined;
|
||||||
|
locale: string;
|
||||||
|
monthIndex: number;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
function formatYear(date: DateValue) {
|
||||||
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
|
if (typeof yearFormat === "function") return yearFormat(dateObj.getFullYear());
|
||||||
|
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonth(date: DateValue) {
|
||||||
|
const dateObj = date.toDate(getLocalTimeZone());
|
||||||
|
if (typeof monthFormat === "function") return monthFormat(dateObj.getMonth() + 1);
|
||||||
|
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet MonthSelect()}
|
||||||
|
<CalendarMonthSelect
|
||||||
|
{months}
|
||||||
|
{monthFormat}
|
||||||
|
value={month.month}
|
||||||
|
onchange={(e) => {
|
||||||
|
if (!placeholder) return;
|
||||||
|
const v = Number.parseInt(e.currentTarget.value);
|
||||||
|
const newPlaceholder = placeholder.set({ month: v });
|
||||||
|
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet YearSelect()}
|
||||||
|
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if captionLayout === "dropdown"}
|
||||||
|
{@render MonthSelect()}
|
||||||
|
{@render YearSelect()}
|
||||||
|
{:else if captionLayout === "dropdown-months"}
|
||||||
|
{@render MonthSelect()}
|
||||||
|
{#if placeholder}
|
||||||
|
{formatYear(placeholder)}
|
||||||
|
{/if}
|
||||||
|
{:else if captionLayout === "dropdown-years"}
|
||||||
|
{#if placeholder}
|
||||||
|
{formatMonth(placeholder)}
|
||||||
|
{/if}
|
||||||
|
{@render YearSelect()}
|
||||||
|
{:else}
|
||||||
|
{formatMonth(month)} {formatYear(month)}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.CellProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Cell
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"size-(--cell-size) relative p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { buttonVariants } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.DayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Day
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant: "ghost" }),
|
||||||
|
"size-(--cell-size) flex select-none flex-col items-center justify-center gap-1 whitespace-nowrap p-0 font-normal leading-none",
|
||||||
|
"[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground",
|
||||||
|
"data-[selected]:bg-primary dark:data-[selected]:hover:bg-accent/50 data-[selected]:text-primary-foreground",
|
||||||
|
// Outside months
|
||||||
|
"[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground",
|
||||||
|
// Disabled
|
||||||
|
"data-[disabled]:text-muted-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
// Unavailable
|
||||||
|
"data-[unavailable]:text-muted-foreground data-[unavailable]:line-through",
|
||||||
|
// hover
|
||||||
|
"dark:hover:text-accent-foreground",
|
||||||
|
// focus
|
||||||
|
"focus:border-ring focus:ring-ring/50 focus:relative",
|
||||||
|
// inner spans
|
||||||
|
"[&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridBodyProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridHeadProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridRowProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.GridRow bind:ref class={cn("flex", className)} {...restProps} />
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.GridProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Grid
|
||||||
|
bind:ref
|
||||||
|
class={cn("mt-4 flex w-full border-collapse flex-col gap-1", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.HeadCellProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.HeadCell
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"text-muted-foreground w-(--cell-size) rounded-md text-[0.8rem] font-normal",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.HeaderProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Header
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
"h-(--cell-size) flex w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.HeadingProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CalendarPrimitive.Heading
|
||||||
|
bind:ref
|
||||||
|
class={cn("px-(--cell-size) text-sm font-medium", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
value,
|
||||||
|
onchange,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||||
|
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||||
|
<select {...props} {value} {onchange}>
|
||||||
|
{#each monthItems as monthItem (monthItem.value)}
|
||||||
|
<option
|
||||||
|
value={monthItem.value}
|
||||||
|
selected={value !== undefined
|
||||||
|
? monthItem.value === value
|
||||||
|
: monthItem.value === selectedMonthItem.value}
|
||||||
|
>
|
||||||
|
{monthItem.label}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||||
|
<ChevronDownIcon class="size-4" />
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</CalendarPrimitive.MonthSelect>
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type WithElementRef, cn } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div {...restProps} bind:this={ref} class={cn("flex flex-col", className)}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("relative flex flex-col gap-4 md:flex-row", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
{...restProps}
|
||||||
|
bind:this={ref}
|
||||||
|
class={cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", className)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import ChevronRightIcon from "@lucide/svelte/icons/chevron-right";
|
||||||
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
variant = "ghost",
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.NextButtonProps & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
<ChevronRightIcon class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<CalendarPrimitive.NextButton
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant }),
|
||||||
|
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
children={children || Fallback}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import ChevronLeftIcon from "@lucide/svelte/icons/chevron-left";
|
||||||
|
import { buttonVariants, type ButtonVariant } from "$lib/components/ui/button/index.js";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
variant = "ghost",
|
||||||
|
...restProps
|
||||||
|
}: CalendarPrimitive.PrevButtonProps & {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet Fallback()}
|
||||||
|
<ChevronLeftIcon class="size-4" />
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<CalendarPrimitive.PrevButton
|
||||||
|
bind:ref
|
||||||
|
class={cn(
|
||||||
|
buttonVariants({ variant }),
|
||||||
|
"size-(--cell-size) select-none bg-transparent p-0 disabled:opacity-50 rtl:rotate-180",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
children={children || Fallback}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
value,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class={cn(
|
||||||
|
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative flex rounded-md border",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||||
|
{#snippet child({ props, yearItems, selectedYearItem })}
|
||||||
|
<select {...props} {value}>
|
||||||
|
{#each yearItems as yearItem (yearItem.value)}
|
||||||
|
<option
|
||||||
|
value={yearItem.value}
|
||||||
|
selected={value !== undefined
|
||||||
|
? yearItem.value === value
|
||||||
|
: yearItem.value === selectedYearItem.value}
|
||||||
|
>
|
||||||
|
{yearItem.label}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<span
|
||||||
|
class="[&>svg]:text-muted-foreground flex h-8 select-none items-center gap-1 rounded-md pl-2 pr-1 text-sm font-medium [&>svg]:size-3.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||||
|
<ChevronDownIcon class="size-4" />
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
</CalendarPrimitive.YearSelect>
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||||
|
import * as Calendar from './index.js';
|
||||||
|
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||||
|
import type { ButtonVariant } from '../button/button.svelte';
|
||||||
|
import { isEqualMonth, type DateValue } from '@internationalized/date';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
placeholder = $bindable(),
|
||||||
|
class: className,
|
||||||
|
weekdayFormat = 'short',
|
||||||
|
buttonVariant = 'ghost',
|
||||||
|
captionLayout = 'label',
|
||||||
|
locale = 'en-US',
|
||||||
|
months: monthsProp,
|
||||||
|
years,
|
||||||
|
monthFormat: monthFormatProp,
|
||||||
|
yearFormat = 'numeric',
|
||||||
|
day,
|
||||||
|
disableDaysOutsideMonth = false,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
||||||
|
buttonVariant?: ButtonVariant;
|
||||||
|
captionLayout?: 'dropdown' | 'dropdown-months' | 'dropdown-years' | 'label';
|
||||||
|
months?: CalendarPrimitive.MonthSelectProps['months'];
|
||||||
|
years?: CalendarPrimitive.YearSelectProps['years'];
|
||||||
|
monthFormat?: CalendarPrimitive.MonthSelectProps['monthFormat'];
|
||||||
|
yearFormat?: CalendarPrimitive.YearSelectProps['yearFormat'];
|
||||||
|
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const monthFormat = $derived.by(() => {
|
||||||
|
if (monthFormatProp) return monthFormatProp;
|
||||||
|
if (captionLayout.startsWith('dropdown')) return 'short';
|
||||||
|
return 'long';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Discriminated Unions + Destructing (required for bindable) do not
|
||||||
|
get along, so we shut typescript up by casting `value` to `never`.
|
||||||
|
-->
|
||||||
|
<CalendarPrimitive.Root
|
||||||
|
bind:value={value as never}
|
||||||
|
bind:ref
|
||||||
|
bind:placeholder
|
||||||
|
{weekdayFormat}
|
||||||
|
{disableDaysOutsideMonth}
|
||||||
|
class={cn(
|
||||||
|
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||||
|
// Add fuchsia theme styles for selected date
|
||||||
|
'[&_[data-selected]]:border-primary [&_[data-selected]]:text-primary [&_[data-selected]]:rounded-md [&_[data-selected]]:bg-fuchsia-200 [&_[data-selected]]:shadow-sm',
|
||||||
|
'[&_[data-selected]:hover]:bg-fuchsia-50',
|
||||||
|
// Ensure today indicator works with fuchsia theme
|
||||||
|
'[&_[data-today]:not([data-selected])]:bg-accent [&_[data-today]:not([data-selected])]:text-accent-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{locale}
|
||||||
|
{monthFormat}
|
||||||
|
{yearFormat}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ months, weekdays })}
|
||||||
|
<Calendar.Months>
|
||||||
|
<Calendar.Nav>
|
||||||
|
<Calendar.PrevButton variant={buttonVariant} />
|
||||||
|
<Calendar.NextButton variant={buttonVariant} />
|
||||||
|
</Calendar.Nav>
|
||||||
|
{#each months as month, monthIndex (month)}
|
||||||
|
<Calendar.Month>
|
||||||
|
<Calendar.Header>
|
||||||
|
<Calendar.Caption
|
||||||
|
{captionLayout}
|
||||||
|
months={monthsProp}
|
||||||
|
{monthFormat}
|
||||||
|
{years}
|
||||||
|
{yearFormat}
|
||||||
|
month={month.value}
|
||||||
|
bind:placeholder
|
||||||
|
{locale}
|
||||||
|
{monthIndex}
|
||||||
|
/>
|
||||||
|
</Calendar.Header>
|
||||||
|
<Calendar.Grid>
|
||||||
|
<Calendar.GridHead>
|
||||||
|
<Calendar.GridRow class="select-none">
|
||||||
|
{#each weekdays as weekday (weekday)}
|
||||||
|
<Calendar.HeadCell>
|
||||||
|
{weekday.slice(0, 2)}
|
||||||
|
</Calendar.HeadCell>
|
||||||
|
{/each}
|
||||||
|
</Calendar.GridRow>
|
||||||
|
</Calendar.GridHead>
|
||||||
|
<Calendar.GridBody>
|
||||||
|
{#each month.weeks as weekDates (weekDates)}
|
||||||
|
<Calendar.GridRow class="mt-2 w-full">
|
||||||
|
{#each weekDates as date (date)}
|
||||||
|
<Calendar.Cell {date} month={month.value}>
|
||||||
|
{#if day}
|
||||||
|
{@render day({
|
||||||
|
day: date,
|
||||||
|
outsideMonth: !isEqualMonth(date, month.value)
|
||||||
|
})}
|
||||||
|
{:else}
|
||||||
|
<Calendar.Day />
|
||||||
|
{/if}
|
||||||
|
</Calendar.Cell>
|
||||||
|
{/each}
|
||||||
|
</Calendar.GridRow>
|
||||||
|
{/each}
|
||||||
|
</Calendar.GridBody>
|
||||||
|
</Calendar.Grid>
|
||||||
|
</Calendar.Month>
|
||||||
|
{/each}
|
||||||
|
</Calendar.Months>
|
||||||
|
{/snippet}
|
||||||
|
</CalendarPrimitive.Root>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import Root from "./calendar.svelte";
|
||||||
|
import Cell from "./calendar-cell.svelte";
|
||||||
|
import Day from "./calendar-day.svelte";
|
||||||
|
import Grid from "./calendar-grid.svelte";
|
||||||
|
import Header from "./calendar-header.svelte";
|
||||||
|
import Months from "./calendar-months.svelte";
|
||||||
|
import GridRow from "./calendar-grid-row.svelte";
|
||||||
|
import Heading from "./calendar-heading.svelte";
|
||||||
|
import GridBody from "./calendar-grid-body.svelte";
|
||||||
|
import GridHead from "./calendar-grid-head.svelte";
|
||||||
|
import HeadCell from "./calendar-head-cell.svelte";
|
||||||
|
import NextButton from "./calendar-next-button.svelte";
|
||||||
|
import PrevButton from "./calendar-prev-button.svelte";
|
||||||
|
import MonthSelect from "./calendar-month-select.svelte";
|
||||||
|
import YearSelect from "./calendar-year-select.svelte";
|
||||||
|
import Month from "./calendar-month.svelte";
|
||||||
|
import Nav from "./calendar-nav.svelte";
|
||||||
|
import Caption from "./calendar-caption.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Day,
|
||||||
|
Cell,
|
||||||
|
Grid,
|
||||||
|
Header,
|
||||||
|
Months,
|
||||||
|
GridRow,
|
||||||
|
Heading,
|
||||||
|
GridBody,
|
||||||
|
GridHead,
|
||||||
|
HeadCell,
|
||||||
|
NextButton,
|
||||||
|
PrevButton,
|
||||||
|
Nav,
|
||||||
|
Month,
|
||||||
|
YearSelect,
|
||||||
|
MonthSelect,
|
||||||
|
Caption,
|
||||||
|
//
|
||||||
|
Root as Calendar,
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-action"
|
||||||
|
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-footer"
|
||||||
|
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-header"
|
||||||
|
class={cn(
|
||||||
|
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card-title"
|
||||||
|
class={cn("font-semibold leading-none", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="card"
|
||||||
|
class={cn('flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground', className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Root from "./card.svelte";
|
||||||
|
import Content from "./card-content.svelte";
|
||||||
|
import Description from "./card-description.svelte";
|
||||||
|
import Footer from "./card-footer.svelte";
|
||||||
|
import Header from "./card-header.svelte";
|
||||||
|
import Title from "./card-title.svelte";
|
||||||
|
import Action from "./card-action.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Title,
|
||||||
|
Action,
|
||||||
|
//
|
||||||
|
Root as Card,
|
||||||
|
Content as CardContent,
|
||||||
|
Description as CardDescription,
|
||||||
|
Footer as CardFooter,
|
||||||
|
Header as CardHeader,
|
||||||
|
Title as CardTitle,
|
||||||
|
Action as CardAction,
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
||||||
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
|
import MinusIcon from "@lucide/svelte/icons/minus";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
checked = $bindable(false),
|
||||||
|
indeterminate = $bindable(false),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="checkbox"
|
||||||
|
class={cn(
|
||||||
|
"border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive shadow-xs peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:checked
|
||||||
|
bind:indeterminate
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ checked, indeterminate })}
|
||||||
|
<div data-slot="checkbox-indicator" class="text-current transition-none">
|
||||||
|
{#if checked}
|
||||||
|
<CheckIcon class="size-3.5" />
|
||||||
|
{:else if indeterminate}
|
||||||
|
<MinusIcon class="size-3.5" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import Root from "./checkbox.svelte";
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Checkbox,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import XIcon from "@lucide/svelte/icons/x";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import * as Dialog from "./index.js";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
|
||||||
|
portalProps?: DialogPrimitive.PortalProps;
|
||||||
|
children: Snippet;
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Portal {...portalProps}>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
{#if showCloseButton}
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
class="ring-offset-background focus:ring-ring rounded-xs focus:outline-hidden absolute end-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
{/if}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.DescriptionProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="dialog-header"
|
||||||
|
class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.OverlayProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
class={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: DialogPrimitive.TitleProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
bind:ref
|
||||||
|
data-slot="dialog-title"
|
||||||
|
class={cn("text-lg font-semibold leading-none", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import Title from "./dialog-title.svelte";
|
||||||
|
import Footer from "./dialog-footer.svelte";
|
||||||
|
import Header from "./dialog-header.svelte";
|
||||||
|
import Overlay from "./dialog-overlay.svelte";
|
||||||
|
import Content from "./dialog-content.svelte";
|
||||||
|
import Description from "./dialog-description.svelte";
|
||||||
|
import Trigger from "./dialog-trigger.svelte";
|
||||||
|
import Close from "./dialog-close.svelte";
|
||||||
|
|
||||||
|
const Root = DialogPrimitive.Root;
|
||||||
|
const Portal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Title,
|
||||||
|
Portal,
|
||||||
|
Footer,
|
||||||
|
Header,
|
||||||
|
Trigger,
|
||||||
|
Overlay,
|
||||||
|
Content,
|
||||||
|
Description,
|
||||||
|
Close,
|
||||||
|
//
|
||||||
|
Root as Dialog,
|
||||||
|
Title as DialogTitle,
|
||||||
|
Portal as DialogPortal,
|
||||||
|
Footer as DialogFooter,
|
||||||
|
Header as DialogHeader,
|
||||||
|
Trigger as DialogTrigger,
|
||||||
|
Overlay as DialogOverlay,
|
||||||
|
Content as DialogContent,
|
||||||
|
Description as DialogDescription,
|
||||||
|
Close as DialogClose,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Button from "$lib/components/ui/button/index.js";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: Button.Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button.Root bind:ref type="submit" {...restProps} />
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<FormPrimitive.DescriptionProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.Description
|
||||||
|
bind:ref
|
||||||
|
data-slot="form-description"
|
||||||
|
class={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import type { FormPathLeaves } from "sveltekit-superforms";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> &
|
||||||
|
FormPrimitive.ElementFieldProps<T, U> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.ElementField {form} {name}>
|
||||||
|
{#snippet children({ constraints, errors, tainted, value })}
|
||||||
|
<div bind:this={ref} class={cn("space-y-2", className)} {...restProps}>
|
||||||
|
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FormPrimitive.ElementField>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
errorClasses,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<FormPrimitive.FieldErrorsProps> & {
|
||||||
|
errorClasses?: string | undefined | null;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.FieldErrors
|
||||||
|
bind:ref
|
||||||
|
class={cn("text-destructive text-sm font-medium", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ errors, errorProps })}
|
||||||
|
{#if childrenProp}
|
||||||
|
{@render childrenProp({ errors, errorProps })}
|
||||||
|
{:else}
|
||||||
|
{#each errors as error (error)}
|
||||||
|
<div {...errorProps} class={cn(errorClasses)}>{error}</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</FormPrimitive.FieldErrors>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import type { FormPath } from "sveltekit-superforms";
|
||||||
|
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: FormPrimitive.FieldProps<T, U> &
|
||||||
|
WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.Field {form} {name}>
|
||||||
|
{#snippet children({ constraints, errors, tainted, value })}
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="form-item"
|
||||||
|
class={cn("space-y-2", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</FormPrimitive.Field>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import type { FormPath } from "sveltekit-superforms";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
form,
|
||||||
|
name,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<FormPrimitive.FieldsetProps<T, U>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.Fieldset bind:ref {form} {name} class={cn("space-y-2", className)} {...restProps} />
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import { Label } from "$lib/components/ui/label/index.js";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
children,
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<FormPrimitive.LabelProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.Label {...restProps} bind:ref>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Label
|
||||||
|
{...props}
|
||||||
|
data-slot="form-label"
|
||||||
|
class={cn("data-[fs-error]:text-destructive", className)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</Label>
|
||||||
|
{/snippet}
|
||||||
|
</FormPrimitive.Label>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<FormPrimitive.LegendProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FormPrimitive.Legend
|
||||||
|
bind:ref
|
||||||
|
class={cn("data-[fs-error]:text-destructive text-sm font-medium leading-none", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as FormPrimitive from "formsnap";
|
||||||
|
import Description from "./form-description.svelte";
|
||||||
|
import Label from "./form-label.svelte";
|
||||||
|
import FieldErrors from "./form-field-errors.svelte";
|
||||||
|
import Field from "./form-field.svelte";
|
||||||
|
import Fieldset from "./form-fieldset.svelte";
|
||||||
|
import Legend from "./form-legend.svelte";
|
||||||
|
import ElementField from "./form-element-field.svelte";
|
||||||
|
import Button from "./form-button.svelte";
|
||||||
|
|
||||||
|
const Control = FormPrimitive.Control;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Field,
|
||||||
|
Control,
|
||||||
|
Label,
|
||||||
|
Button,
|
||||||
|
FieldErrors,
|
||||||
|
Description,
|
||||||
|
Fieldset,
|
||||||
|
Legend,
|
||||||
|
ElementField,
|
||||||
|
//
|
||||||
|
Field as FormField,
|
||||||
|
Control as FormControl,
|
||||||
|
Description as FormDescription,
|
||||||
|
Label as FormLabel,
|
||||||
|
FieldErrors as FormFieldErrors,
|
||||||
|
Fieldset as FormFieldset,
|
||||||
|
Legend as FormLegend,
|
||||||
|
ElementField as FormElementField,
|
||||||
|
Button as FormButton,
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./input.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Input,
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
|
||||||
|
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
||||||
|
|
||||||
|
type Props = WithElementRef<
|
||||||
|
Omit<HTMLInputAttributes, "type"> &
|
||||||
|
({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
|
||||||
|
>;
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
value = $bindable(),
|
||||||
|
type,
|
||||||
|
files = $bindable(),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if type === "file"}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="input"
|
||||||
|
class={cn(
|
||||||
|
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
type="file"
|
||||||
|
bind:files
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="input"
|
||||||
|
class={cn(
|
||||||
|
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{type}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import Root from "./label.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
//
|
||||||
|
Root as Label,
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label as LabelPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: LabelPrimitive.RootProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
data-slot="label"
|
||||||
|
class={cn(
|
||||||
|
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import Group from "./select-group.svelte";
|
||||||
|
import Label from "./select-label.svelte";
|
||||||
|
import Item from "./select-item.svelte";
|
||||||
|
import Content from "./select-content.svelte";
|
||||||
|
import Trigger from "./select-trigger.svelte";
|
||||||
|
import Separator from "./select-separator.svelte";
|
||||||
|
import ScrollDownButton from "./select-scroll-down-button.svelte";
|
||||||
|
import ScrollUpButton from "./select-scroll-up-button.svelte";
|
||||||
|
import GroupHeading from "./select-group-heading.svelte";
|
||||||
|
|
||||||
|
const Root = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Group,
|
||||||
|
Label,
|
||||||
|
Item,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Separator,
|
||||||
|
ScrollDownButton,
|
||||||
|
ScrollUpButton,
|
||||||
|
GroupHeading,
|
||||||
|
//
|
||||||
|
Root as Select,
|
||||||
|
Group as SelectGroup,
|
||||||
|
Label as SelectLabel,
|
||||||
|
Item as SelectItem,
|
||||||
|
Content as SelectContent,
|
||||||
|
Trigger as SelectTrigger,
|
||||||
|
Separator as SelectSeparator,
|
||||||
|
ScrollDownButton as SelectScrollDownButton,
|
||||||
|
ScrollUpButton as SelectScrollUpButton,
|
||||||
|
GroupHeading as SelectGroupHeading,
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
|
||||||
|
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
sideOffset = 4,
|
||||||
|
portalProps,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.ContentProps> & {
|
||||||
|
portalProps?: SelectPrimitive.PortalProps;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Portal {...portalProps}>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
bind:ref
|
||||||
|
{sideOffset}
|
||||||
|
data-slot="select-content"
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin) relative z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
class={cn(
|
||||||
|
"h-(--bits-select-anchor-height) min-w-(--bits-select-anchor-width) w-full scroll-my-1 p-1"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
import type { ComponentProps } from "svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.GroupHeading
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-group-heading"
|
||||||
|
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</SelectPrimitive.GroupHeading>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Group data-slot="select-group" {...restProps} />
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CheckIcon from "@lucide/svelte/icons/check";
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
children: childrenProp,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
bind:ref
|
||||||
|
{value}
|
||||||
|
data-slot="select-item"
|
||||||
|
class={cn(
|
||||||
|
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet children({ selected, highlighted })}
|
||||||
|
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
{#if selected}
|
||||||
|
<CheckIcon class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if childrenProp}
|
||||||
|
{@render childrenProp({ selected, highlighted })}
|
||||||
|
{:else}
|
||||||
|
{label || value}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</SelectPrimitive.Item>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="select-label"
|
||||||
|
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
bind:ref
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
class={cn("flex cursor-default items-center justify-center py-1", className)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon class="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user