Initial commit. Working login, example UI with prototype and demo, connections to DB and DAV, local and prod setups.

This commit is contained in:
2025-10-12 22:13:19 +01:00
commit 708b741a32
138 changed files with 7797 additions and 0 deletions
+99
View File
@@ -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
+13
View File
@@ -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"]
+56
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
package auth
+65
View File
@@ -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 ""
}
+64
View File
@@ -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 ""
}
+34
View File
@@ -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
)
+62
View File
@@ -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=
+1
View File
@@ -0,0 +1 @@
package admin
+1
View File
@@ -0,0 +1 @@
package admin
+1
View File
@@ -0,0 +1 @@
package admin
+354
View File
@@ -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(&currentRole)
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})
}
+1
View File
@@ -0,0 +1 @@
package auth
+12
View File
@@ -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)
}
+34
View File
@@ -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)
}
+209
View File
@@ -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)
}
+54
View File
@@ -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 ""
}
+55
View File
@@ -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 ""
}
+276
View File
@@ -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)
}
+187
View File
@@ -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
View File
@@ -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)
}
+91
View File
@@ -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
View File
@@ -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:
+23
View File
@@ -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-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+9
View File
@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
+16
View File
@@ -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"
}
+38
View File
@@ -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.
+16
View File
@@ -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"
}
+41
View File
@@ -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
}
}
}
);
+57
View File
@@ -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"
}
}
+132
View File
@@ -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;
}
+14
View File
@@ -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';
+11
View File
@@ -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>
+1
View File
@@ -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