Initial commit

This commit is contained in:
2026-02-04 00:17:30 +00:00
commit 890e52af8c
127 changed files with 9682 additions and 0 deletions

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
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-*
*.db
*.log
bun.lock
.vscode
.svelte-kit
target/

24
README.md Normal file
View File

@@ -0,0 +1,24 @@
# Clickploy
A minimal, self-hosted Platform as a Service (PaaS) for building and deploying applications quickly.
## Features
- **Auto-Build**: Uses `nixpacks` to automatically detect and build your application from a Git repository.
- **Docker-based**: Each deployment runs in an isolated Docker container.
- **Real-time Logs**: View build and runtime logs streamed directly to the dashboard.
- **Port Management**: Automatically assigns and manages ports for your applications.
- **Zero Configuration**: Just provide a repo URL, and Clickploy handles the rest.
## Tech Stack
- **Frontend**: SvelteKit, Tailwind CSS, Shadcn-Svelte
- **Backend**: Go (Gin), GORM (SQLite), Docker SDK
- **Build Engine**: Nixpacks
- **Database**: SQLite (Embedded)
## Architecture
Clickploy acts as a control plane for your deployments. It clones your repository, builds a Docker image using Nixpacks, and spins up a container. It manages a persistent database of projects and deployments, ensuring state is maintained across restarts.
## License
MIT

28
backend/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM golang:1.23-bullseye
# Install dependencies required for Nixpacks and Git interactions
RUN apt-get update && apt-get install -y \
git \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Nixpacks
RUN curl -sSL https://nixpacks.com/install.sh | bash
WORKDIR /app
# Cache Go modules
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build the binary
RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server
# Expose API port
EXPOSE 8080
# Run the server
CMD ["./server"]

28
backend/README.md Normal file
View File

@@ -0,0 +1,28 @@
# Clickploy Backend
The backend control plane for Clickploy, written in Go.
## Overview
This service handles:
- **Project Management**: Creating and listing projects.
- **Port Assignment**: Allocating ports (4000-5000) for deployments.
- **Builds**: Using `nixpacks` to build Docker images from Git URLs.
- **Deployment**: interfacing with the Docker daemon to run containers.
- **Logs**: Streaming build and runtime logs via WebSockets.
- **Webhooks**: Handling Git push events.
### API Endpoints
- `GET /api/projects`: List projects
- `POST /api/projects`: Create a project
- `GET /api/activity`: Get recent activity
- `WS /api/deployments/:id/logs/stream`: Stream logs
## Structure
- `cmd/server/`: Entry point.
- `internal/api/`: Gin HTTP handlers.
- `internal/db/`: SQLite database setup.
- `internal/models/`: GORM models.
- `internal/builder/`: Nixpacks wrapper.
- `internal/deployer/`: Docker SDK wrapper.

View File

@@ -0,0 +1,51 @@
package main
import (
"log"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"clickploy/internal/api"
"clickploy/internal/builder"
"clickploy/internal/db"
"clickploy/internal/deployer"
"clickploy/internal/ports"
)
func main() {
db.Init(".")
pm := ports.NewManager(4000, 5000)
buildr := builder.NewBuilder()
dply, err := deployer.NewDeployer()
if err != nil {
log.Fatalf("Failed to create deployer: %v", err)
}
handler := api.NewHandler(buildr, dply, pm)
r := gin.Default()
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
handler.RegisterRoutes(r)
handler.RegisterAuthRoutes(r)
handler.RegisterUserRoutes(r)
handler.RegisterProjectRoutes(r)
handler.RegisterWebhookRoutes(r)
handler.RegisterSystemRoutes(r)
handler.RegisterStorageRoutes(r)
log.Println("Starting Clickploy Backend on :8080")
if err := r.Run(":8080"); err != nil {
log.Fatalf("Server failed: %v", err)
}
}

66
backend/go.mod Normal file
View File

@@ -0,0 +1,66 @@
module clickploy
go 1.24.0
toolchain go1.24.12
require (
github.com/docker/docker v24.0.7+incompatible
github.com/docker/go-connections v0.6.0
github.com/gin-contrib/cors v1.5.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.3.1
golang.org/x/crypto v0.47.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/Microsoft/go-winio v0.4.21 // indirect
github.com/bytedance/sonic v1.10.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect
github.com/distribution/reference v0.0.0-00010101000000-000000000000 // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.15.5 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.5.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
replace (
github.com/distribution/reference => github.com/distribution/reference v0.5.0
github.com/docker/docker => github.com/docker/docker v24.0.7+incompatible
)

178
backend/go.sum Normal file
View File

@@ -0,0 +1,178 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro=
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc=
github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v24.0.7+incompatible h1:Wo6l37AuwP3JaMnZa226lzVXGA3F9Ig1seQen0cKYlM=
github.com/docker/docker v24.0.7+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk=
github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24=
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -0,0 +1,92 @@
package api
import (
"fmt"
"net/http"
"os"
"github.com/gin-gonic/gin"
"clickploy/internal/builder"
"clickploy/internal/deployer"
"clickploy/internal/ports"
)
type Handler struct {
builder *builder.Builder
deployer *deployer.Deployer
ports *ports.Manager
}
func NewHandler(b *builder.Builder, d *deployer.Deployer, p *ports.Manager) *Handler {
return &Handler{
builder: b,
deployer: d,
ports: p,
}
}
type DeployRequest struct {
Repo string `json:"repo" binding:"required"`
Name string `json:"name" binding:"required"`
Port int `json:"port"`
GitToken string `json:"git_token"`
}
type DeployResponse struct {
Status string `json:"status"`
AppName string `json:"app_name"`
Port int `json:"port"`
URL string `json:"url"`
Message string `json:"message"`
}
func (h *Handler) RegisterRoutes(r *gin.Engine) {
r.POST("/deploy", h.handleDeploy)
h.RegisterStreamRoutes(r)
}
func (h *Handler) handleDeploy(c *gin.Context) {
var req DeployRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
imageName, err := h.builder.Build(req.Repo, req.Name, req.GitToken, "", "", "", "", nil, os.Stdout)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Build failed: %v", err)})
return
}
port, err := h.ports.GetPort(req.Name, req.Port)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Port allocation failed: %v", err)})
return
}
_, err = h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Deployment failed: %v", err)})
return
}
c.JSON(http.StatusOK, DeployResponse{
Status: "success",
AppName: req.Name,
Port: port,
URL: fmt.Sprintf("http://localhost:%d", port),
Message: "Container started successfully",
})
}
func (h *Handler) RegisterSystemRoutes(r *gin.Engine) {
r.GET("/api/system/status", h.handleSystemStatus)
}
func (h *Handler) handleSystemStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"version": "v0.1.0",
"status": "All systems normal",
})
}

View File

@@ -0,0 +1,147 @@
package api
import (
"net/http"
"clickploy/internal/auth"
"clickploy/internal/db"
"clickploy/internal/models"
"github.com/gin-gonic/gin"
)
type AuthRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Name string `json:"name"`
}
type AuthResponse struct {
Token string `json:"token"`
User models.User `json:"user"`
}
func (h *Handler) RegisterAuthRoutes(r *gin.Engine) {
authGroup := r.Group("/auth")
{
authGroup.POST("/register", h.register)
authGroup.POST("/login", h.login)
}
}
func (h *Handler) register(c *gin.Context) {
var req AuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
hashed, err := auth.HashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
return
}
user := models.User{
Email: req.Email,
Password: hashed,
Name: req.Name,
Avatar: "https://github.com/shadcn.png",
}
if result := db.DB.Create(&user); result.Error != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email already exists"})
return
}
token, _ := auth.GenerateToken(user.ID, user.Email)
c.JSON(http.StatusCreated, AuthResponse{Token: token, User: user})
}
func (h *Handler) login(c *gin.Context) {
var req AuthRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if result := db.DB.Where("email = ?", req.Email).First(&user); result.Error != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
if !auth.CheckPassword(req.Password, user.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
token, _ := auth.GenerateToken(user.ID, user.Email)
c.JSON(http.StatusOK, AuthResponse{Token: token, User: user})
}
func (h *Handler) RegisterUserRoutes(r *gin.Engine) {
userGroup := r.Group("/api/user", AuthMiddleware())
{
userGroup.PUT("/profile", h.updateProfile)
userGroup.PUT("/password", h.updatePassword)
}
}
func (h *Handler) updateProfile(c *gin.Context) {
userID, _ := c.Get("userID")
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if result := db.DB.First(&user, userID); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if req.Name != "" {
user.Name = req.Name
}
if req.Email != "" {
user.Email = req.Email
}
db.DB.Save(&user)
c.JSON(http.StatusOK, user)
}
func (h *Handler) updatePassword(c *gin.Context) {
userID, _ := c.Get("userID")
var req struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if result := db.DB.First(&user, userID); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
return
}
if !auth.CheckPassword(req.OldPassword, user.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Incorrect old password"})
return
}
hashed, _ := auth.HashPassword(req.NewPassword)
user.Password = hashed
db.DB.Save(&user)
c.JSON(http.StatusOK, gin.H{"message": "Password updated"})
}

View File

@@ -0,0 +1,33 @@
package api
import (
"net/http"
"strings"
"clickploy/internal/auth"
"github.com/gin-gonic/gin"
)
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
c.Abort()
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := auth.ValidateToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
c.Set("userID", claims.UserID)
c.Set("email", claims.Email)
c.Next()
}
}

View File

@@ -0,0 +1,283 @@
package api
import (
"bytes"
"fmt"
"io"
"net/http"
"strconv"
"time"
"clickploy/internal/auth"
"clickploy/internal/db"
"clickploy/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func (h *Handler) RegisterProjectRoutes(r *gin.Engine) {
protected := r.Group("/api", AuthMiddleware())
{
protected.POST("/projects", h.createProject)
protected.GET("/projects", h.listProjects)
protected.GET("/projects/:id", h.getProject)
protected.PUT("/projects/:id/env", h.updateProjectEnv)
protected.POST("/projects/:id/redeploy", h.redeployProject)
protected.GET("/activity", h.getActivity)
}
}
func (h *Handler) updateProjectEnv(c *gin.Context) {
userID, _ := c.Get("userID")
projectID := c.Param("id")
var req struct {
EnvVars map[string]string `json:"env_vars"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pid, err := strconv.ParseUint(projectID, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"})
return
}
var project models.Project
if result := db.DB.Where("id = ? AND owner_id = ?", pid, userID).First(&project); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
tx := db.DB.Begin()
if err := tx.Where("project_id = ?", project.ID).Delete(&models.EnvVar{}).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update env vars"})
return
}
for k, v := range req.EnvVars {
envVar := models.EnvVar{
ProjectID: project.ID,
Key: k,
Value: v,
}
if err := tx.Create(&envVar).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save env var"})
return
}
}
tx.Commit()
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
func (h *Handler) redeployProject(c *gin.Context) {
userID, _ := c.Get("userID")
projectID := c.Param("id")
pid, err := strconv.ParseUint(projectID, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"})
return
}
var project models.Project
if result := db.DB.Preload("EnvVars").Where("id = ? AND owner_id = ?", pid, userID).First(&project); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
deployment := models.Deployment{
ProjectID: project.ID,
Status: "building",
Commit: "MANUAL",
Logs: "Starting manual redeploy...",
}
db.DB.Create(&deployment)
go func() {
var logBuffer bytes.Buffer
streamer := &StreamWriter{DeploymentID: deployment.ID}
multi := io.MultiWriter(&logBuffer, streamer)
envMap := make(map[string]string)
for _, env := range project.EnvVars {
envMap[env.Key] = env.Value
}
imageName, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi)
deployment.Logs = logBuffer.String()
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nBuild Error: %v", err)
db.DB.Save(&deployment)
return
}
var envStrings []string
for _, env := range project.EnvVars {
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
}
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, project.Name, project.Port, envStrings)
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nContainer Error: %v", err)
db.DB.Save(&deployment)
return
}
deployment.Status = "live"
deployment.URL = fmt.Sprintf("http://localhost:%d", project.Port)
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
db.DB.Save(&deployment)
}()
c.JSON(http.StatusOK, gin.H{"status": "redeployment_started", "deployment_id": deployment.ID})
}
func (h *Handler) getActivity(c *gin.Context) {
userID, _ := c.Get("userID")
var deployments []models.Deployment
err := db.DB.Joins("JOIN projects ON projects.id = deployments.project_id").
Where("projects.owner_id = ?", userID).
Order("deployments.created_at desc").
Limit(20).
Preload("Project").
Find(&deployments).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch activity"})
return
}
c.JSON(http.StatusOK, deployments)
}
func (h *Handler) createProject(c *gin.Context) {
userID, _ := c.Get("userID")
var req struct {
DeployRequest
EnvVars map[string]string `json:"env_vars"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
InstallCommand string `json:"install_command"`
Runtime string `json:"runtime"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
port, err := h.ports.GetPort(req.Name, req.Port)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Port allocation failed: %v", err)})
return
}
var envVarsModel []models.EnvVar
var envStrings []string
for k, v := range req.EnvVars {
envStrings = append(envStrings, fmt.Sprintf("%s=%s", k, v))
envVarsModel = append(envVarsModel, models.EnvVar{Key: k, Value: v})
}
webhookSecret, _ := auth.HashPassword(fmt.Sprintf("%s-%d-%d", req.Name, userID, time.Now().UnixNano()))
project := models.Project{
Name: req.Name,
RepoURL: req.Repo,
OwnerID: userID.(uint),
Port: port,
WebhookSecret: webhookSecret,
GitToken: req.GitToken,
EnvVars: envVarsModel,
BuildCommand: req.BuildCommand,
StartCommand: req.StartCommand,
InstallCommand: req.InstallCommand,
Runtime: req.Runtime,
}
if result := db.DB.Create(&project); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save project to DB"})
return
}
deployment := models.Deployment{
ProjectID: project.ID,
Status: "building",
Commit: "HEAD",
Logs: "Starting build...",
}
db.DB.Create(&deployment)
go func() {
var logBuffer bytes.Buffer
streamer := &StreamWriter{DeploymentID: deployment.ID}
multi := io.MultiWriter(&logBuffer, streamer)
imageName, err := h.builder.Build(req.Repo, req.Name, req.GitToken, req.BuildCommand, req.StartCommand, req.InstallCommand, req.Runtime, req.EnvVars, multi)
deployment.Logs = logBuffer.String()
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nBuild Error: %v", err)
db.DB.Save(&deployment)
return
}
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, req.Name, port, envStrings)
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nContainer Error: %v", err)
db.DB.Save(&deployment)
return
}
deployment.Status = "live"
deployment.URL = fmt.Sprintf("http://localhost:%d", port)
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
db.DB.Save(&deployment)
}()
project.Deployments = []models.Deployment{deployment}
c.JSON(http.StatusOK, project)
}
func (h *Handler) listProjects(c *gin.Context) {
userID, _ := c.Get("userID")
var projects []models.Project
if result := db.DB.Preload("Deployments").Where("owner_id = ?", userID).Find(&projects); result.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch projects"})
return
}
c.JSON(http.StatusOK, projects)
}
func (h *Handler) getProject(c *gin.Context) {
userID, _ := c.Get("userID")
projectID := c.Param("id")
pid, err := strconv.ParseUint(projectID, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"})
return
}
var project models.Project
if result := db.DB.Order("created_at desc").Preload("Deployments", func(db *gorm.DB) *gorm.DB {
return db.Order("deployments.created_at desc")
}).Preload("EnvVars").Where("id = ? AND owner_id = ?", pid, userID).First(&project); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
c.JSON(http.StatusOK, project)
}

View File

@@ -0,0 +1,98 @@
package api
import (
"fmt"
"net/http"
"os"
"path/filepath"
"syscall"
"clickploy/internal/db"
"clickploy/internal/models"
"github.com/gin-gonic/gin"
)
type CreateDatabaseRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required,oneof=sqlite"`
}
type StorageStatsResponse struct {
TotalMB float64 `json:"total_mb"`
UsedMB float64 `json:"used_mb"`
}
func (h *Handler) RegisterStorageRoutes(r *gin.Engine) {
api := r.Group("/api/storage")
api.Use(AuthMiddleware())
{
api.GET("/stats", h.handleGetStorageStats)
api.GET("/databases", h.handleListDatabases)
api.POST("/databases", h.handleCreateDatabase)
}
}
func (h *Handler) handleGetStorageStats(c *gin.Context) {
var stat syscall.Statfs_t
wd, _ := os.Getwd()
if err := syscall.Statfs(wd, &stat); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get disk stats"})
return
}
totalBytes := stat.Blocks * uint64(stat.Bsize)
availBytes := stat.Bavail * uint64(stat.Bsize)
usedBytes := totalBytes - availBytes
c.JSON(http.StatusOK, StorageStatsResponse{
TotalMB: float64(totalBytes) / 1024 / 1024,
UsedMB: float64(usedBytes) / 1024 / 1024,
})
}
func (h *Handler) handleListDatabases(c *gin.Context) {
userId := c.GetUint("userID")
var dbs []models.Database
if err := db.DB.Where("owner_id = ?", userId).Find(&dbs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list databases"})
return
}
c.JSON(http.StatusOK, dbs)
}
func (h *Handler) handleCreateDatabase(c *gin.Context) {
userId := c.GetUint("userID")
var req CreateDatabaseRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
newDB := models.Database{
Name: req.Name,
Type: req.Type,
Status: "available",
OwnerID: userId,
SizeMB: 0,
}
dataDir := "./data/user_dbs"
os.MkdirAll(dataDir, 0755)
dbPath := filepath.Join(dataDir, fmt.Sprintf("%d_%s.db", userId, req.Name))
file, err := os.Create(dbPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create database file"})
return
}
file.Close()
if err := db.DB.Create(&newDB).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save database record"})
return
}
c.JSON(http.StatusOK, newDB)
}

View File

@@ -0,0 +1,111 @@
package api
import (
"net/http"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type LogHub struct {
mu sync.Mutex
streams map[uint][]chan []byte
}
var Hub = &LogHub{
streams: make(map[uint][]chan []byte),
}
func (h *LogHub) Broadcast(deploymentID uint, p []byte) {
h.mu.Lock()
defer h.mu.Unlock()
if sinks, ok := h.streams[deploymentID]; ok {
for _, sink := range sinks {
select {
case sink <- p:
default:
}
}
}
}
func (h *LogHub) Subscribe(deploymentID uint) chan []byte {
h.mu.Lock()
defer h.mu.Unlock()
ch := make(chan []byte, 256)
h.streams[deploymentID] = append(h.streams[deploymentID], ch)
return ch
}
func (h *LogHub) Unsubscribe(deploymentID uint, ch chan []byte) {
h.mu.Lock()
defer h.mu.Unlock()
if sinks, ok := h.streams[deploymentID]; ok {
for i, sink := range sinks {
if sink == ch {
h.streams[deploymentID] = append(sinks[:i], sinks[i+1:]...)
close(ch)
break
}
}
if len(h.streams[deploymentID]) == 0 {
delete(h.streams, deploymentID)
}
}
}
type StreamWriter struct {
DeploymentID uint
}
func (w *StreamWriter) Write(p []byte) (n int, err error) {
c := make([]byte, len(p))
copy(c, p)
Hub.Broadcast(w.DeploymentID, c)
return len(p), nil
}
func (h *Handler) streamDeploymentLogs(c *gin.Context) {
deploymentIDStr := c.Param("id")
deploymentID, err := strconv.ParseUint(deploymentIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
logChan := Hub.Subscribe(uint(deploymentID))
defer Hub.Unsubscribe(uint(deploymentID), logChan)
go func() {
for {
if _, _, err := conn.NextReader(); err != nil {
conn.Close()
return
}
}
}()
for logChunk := range logChan {
conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, logChunk); err != nil {
return
}
}
}
func (h *Handler) RegisterStreamRoutes(r *gin.Engine) {
r.GET("/api/deployments/:id/logs/stream", h.streamDeploymentLogs)
}

View File

@@ -0,0 +1,89 @@
package api
import (
"bytes"
"fmt"
"io"
"net/http"
"strconv"
"clickploy/internal/db"
"clickploy/internal/models"
"github.com/gin-gonic/gin"
)
func (h *Handler) RegisterWebhookRoutes(r *gin.Engine) {
r.POST("/webhooks/trigger", h.handleWebhook)
}
func (h *Handler) handleWebhook(c *gin.Context) {
projectIDHex := c.Query("project_id")
if projectIDHex == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project_id required"})
return
}
pid, err := strconv.ParseUint(projectIDHex, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Project ID"})
return
}
var project models.Project
if result := db.DB.Preload("EnvVars").First(&project, pid); result.Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
return
}
deployment := models.Deployment{
ProjectID: project.ID,
Status: "building",
Commit: "WEBHOOK",
Logs: "Webhook triggered. Starting build...",
}
if err := db.DB.Create(&deployment).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deployment record"})
return
}
go func() {
var logBuffer bytes.Buffer
streamer := &StreamWriter{DeploymentID: deployment.ID}
multi := io.MultiWriter(&logBuffer, streamer)
envMap := make(map[string]string)
for _, env := range project.EnvVars {
envMap[env.Key] = env.Value
}
imageName, err := h.builder.Build(project.RepoURL, project.Name, project.GitToken, project.BuildCommand, project.StartCommand, project.InstallCommand, project.Runtime, envMap, multi)
deployment.Logs = logBuffer.String()
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nBuild Failed: %v", err)
db.DB.Save(&deployment)
return
}
var envStrings []string
for _, env := range project.EnvVars {
envStrings = append(envStrings, fmt.Sprintf("%s=%s", env.Key, env.Value))
}
containerID, err := h.deployer.RunContainer(c.Request.Context(), imageName, project.Name, project.Port, envStrings)
if err != nil {
deployment.Status = "failed"
deployment.Logs += fmt.Sprintf("\n\nDeployment Failed: %v", err)
db.DB.Save(&deployment)
return
}
deployment.Status = "live"
deployment.URL = fmt.Sprintf("http://localhost:%d", project.Port)
deployment.Logs += fmt.Sprintf("\n\nContainer ID: %s", containerID)
db.DB.Save(&deployment)
}()
c.JSON(http.StatusOK, gin.H{"status": "redeployment_started", "deployment_id": deployment.ID})
}

View File

@@ -0,0 +1,54 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
var SecretKey = []byte("super-secret-key-change-me")
type Claims struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
return string(bytes), err
}
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
func GenerateToken(userID uint, email string) (string, error) {
expirationTime := time.Now().Add(24 * time.Hour)
claims := &Claims{
UserID: userID,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expirationTime),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(SecretKey)
}
func ValidateToken(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return SecretKey, nil
})
if err != nil {
return nil, err
}
if !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}

View File

@@ -0,0 +1,138 @@
package builder
import (
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
)
type Builder struct{}
func NewBuilder() *Builder {
return &Builder{}
}
func (b *Builder) Build(repoURL, appName, gitToken, buildCmd, startCmd, installCmd, runtime string, envVars map[string]string, logWriter io.Writer) (string, error) {
workDir := filepath.Join("/tmp", "paas-builds", appName)
if err := os.RemoveAll(workDir); err != nil {
return "", fmt.Errorf("failed to clean work dir: %w", err)
}
if err := os.MkdirAll(workDir, 0755); err != nil {
return "", fmt.Errorf("failed to create work dir: %w", err)
}
cloneURL := repoURL
if gitToken != "" {
u, err := url.Parse(repoURL)
if err != nil {
return "", fmt.Errorf("invalid repo url: %w", err)
}
u.User = url.UserPassword("oauth2", gitToken)
cloneURL = u.String()
}
fmt.Fprintf(logWriter, ">>> Cloning repository %s...\n", repoURL)
cloneCmd := exec.Command("git", "clone", "--depth", "1", cloneURL, ".")
cloneCmd.Dir = workDir
cloneCmd.Stdout = logWriter
cloneCmd.Stderr = logWriter
if err := cloneCmd.Run(); err != nil {
return "", fmt.Errorf("git clone failed: %w", err)
}
if runtime == "" {
runtime = "nodejs"
}
var nixPkgs string
var defaultInstall, defaultBuild, defaultStart string
switch runtime {
case "bun":
nixPkgs = `["bun"]`
defaultInstall = "bun install"
defaultBuild = "bun run build"
defaultStart = "bun run start"
case "deno":
nixPkgs = `["deno"]`
defaultInstall = "deno cache"
defaultBuild = "deno task build"
defaultStart = "deno task start"
case "pnpm":
nixPkgs = `["nodejs_20", "pnpm"]`
defaultInstall = "pnpm install"
defaultBuild = "pnpm run build"
defaultStart = "pnpm run start"
default:
nixPkgs = `["nodejs_20"]`
defaultInstall = "npm ci --legacy-peer-deps || npm install --legacy-peer-deps"
defaultBuild = "npm run build"
defaultStart = "npm run start"
}
installStr := defaultInstall
if installCmd != "" {
installStr = installCmd
}
buildStr := defaultBuild
if buildCmd != "" {
buildStr = buildCmd
}
startStr := defaultStart
if startCmd != "" {
startStr = startCmd
}
nixpacksConfig := fmt.Sprintf(`
[phases.setup]
nixPkgs = %s
[phases.install]
cmds = ["%s"]
[phases.build]
cmds = ["%s"]
[start]
cmd = "%s"
`, nixPkgs, installStr, buildStr, startStr)
if _, err := os.Stat(filepath.Join(workDir, "package.json")); err == nil {
configPath := filepath.Join(workDir, "nixpacks.toml")
if err := os.WriteFile(configPath, []byte(nixpacksConfig), 0644); err != nil {
return "", fmt.Errorf("failed to write nixpacks.toml: %w", err)
}
}
imageName := strings.ToLower(appName)
fmt.Fprintf(logWriter, "\n>>> Starting Nixpacks build for %s...\n", imageName)
args := []string{"build", ".", "--name", imageName, "--no-cache"}
for k, v := range envVars {
args = append(args, "--env", fmt.Sprintf("%s=%s", k, v))
}
nixCmd := exec.Command("nixpacks", args...)
nixCmd.Dir = workDir
nixCmd.Stdout = logWriter
nixCmd.Stderr = logWriter
nixCmd.Env = append(os.Environ(),
"NIXPACKS_NO_CACHE=1",
)
if err := nixCmd.Run(); err != nil {
return "", fmt.Errorf("nixpacks build failed: %w", err)
}
fmt.Fprintf(logWriter, "\n>>> Build successful!\n")
return imageName, nil
}

33
backend/internal/db/db.go Normal file
View File

@@ -0,0 +1,33 @@
package db
import (
"log"
"path/filepath"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"clickploy/internal/models"
)
var DB *gorm.DB
func Init(storagePath string) {
var err error
dbPath := filepath.Join(storagePath, "clickploy.db")
DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info),
})
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
log.Println("Migrating database...")
err = DB.AutoMigrate(&models.User{}, &models.Project{}, &models.Deployment{}, &models.EnvVar{}, &models.Database{})
if err != nil {
log.Fatal("Failed to migrate database:", err)
}
log.Println("Database initialized successfully")
}

View File

@@ -0,0 +1,65 @@
package deployer
import (
"context"
"fmt"
"io"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/docker/go-connections/nat"
)
type Deployer struct {
cli *client.Client
}
func NewDeployer() (*Deployer, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("failed to create docker client: %w", err)
}
return &Deployer{cli: cli}, nil
}
func (d *Deployer) RunContainer(ctx context.Context, imageName, appName string, hostPort int, envVars []string) (string, error) {
config := &container.Config{
Image: imageName,
ExposedPorts: nat.PortSet{
"3000/tcp": struct{}{},
},
Env: envVars,
}
hostConfig := &container.HostConfig{
PortBindings: nat.PortMap{
"3000/tcp": []nat.PortBinding{
{
HostIP: "0.0.0.0",
HostPort: fmt.Sprintf("%d", hostPort),
},
},
},
RestartPolicy: container.RestartPolicy{
Name: "unless-stopped",
},
}
_ = d.cli.ContainerRemove(ctx, appName, types.ContainerRemoveOptions{Force: true})
resp, err := d.cli.ContainerCreate(ctx, config, hostConfig, nil, nil, appName)
if err != nil {
return "", fmt.Errorf("failed to create container: %w", err)
}
if err := d.cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil {
return "", fmt.Errorf("failed to start container: %w", err)
}
return resp.ID, nil
}
func (d *Deployer) StreamLogs(ctx context.Context, containerID string) (io.ReadCloser, error) {
return d.cli.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true, Follow: true})
}

View File

@@ -0,0 +1,56 @@
package models
import (
"gorm.io/gorm"
)
type User struct {
gorm.Model
Email string `gorm:"uniqueIndex;not null" json:"email"`
Password string `json:"-"`
Name string `json:"name"`
Avatar string `json:"avatar"`
Projects []Project `gorm:"foreignKey:OwnerID" json:"projects"`
}
type Project struct {
gorm.Model
Name string `gorm:"uniqueIndex" json:"name"`
RepoURL string `json:"repo_url"`
OwnerID uint `json:"owner_id"`
Port int `json:"port"`
WebhookSecret string `json:"webhook_secret"`
GitToken string `json:"-"`
BuildCommand string `json:"build_command"`
StartCommand string `json:"start_command"`
InstallCommand string `json:"install_command"`
Runtime string `json:"runtime"`
Deployments []Deployment `gorm:"foreignKey:ProjectID" json:"deployments"`
EnvVars []EnvVar `gorm:"foreignKey:ProjectID" json:"env_vars"`
}
type EnvVar struct {
gorm.Model
ProjectID uint `json:"project_id"`
Key string `json:"key"`
Value string `json:"value"`
}
type Deployment struct {
gorm.Model
ProjectID uint `json:"project_id"`
Project Project `json:"project" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
Status string `json:"status"`
Commit string `json:"commit"`
Logs string `json:"logs"`
URL string `json:"url"`
}
type Database struct {
gorm.Model
Name string `json:"name"`
Type string `json:"type"`
Status string `json:"status"`
OwnerID uint `json:"owner_id"`
SizeMB float64 `json:"size_mb"`
}

View File

@@ -0,0 +1,80 @@
package ports
import (
"fmt"
"net"
"sync"
)
type Manager struct {
mu sync.Mutex
startPort int
endPort int
allocations map[string]int
}
func NewManager(start, end int) *Manager {
return &Manager{
startPort: start,
endPort: end,
allocations: make(map[string]int),
}
}
func (m *Manager) GetPort(appName string, specificPort int) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
if port, exists := m.allocations[appName]; exists {
if specificPort > 0 && specificPort != port {
return 0, fmt.Errorf("app %s is already running on port %d", appName, port)
}
return port, nil
}
if specificPort > 0 {
if err := m.checkPortAvailable(specificPort); err != nil {
return 0, err
}
m.allocations[appName] = specificPort
return specificPort, nil
}
for port := m.startPort; port <= m.endPort; port++ {
if err := m.checkPortAvailable(port); err == nil {
if !m.isPortAllocatedInternal(port) {
m.allocations[appName] = port
return port, nil
}
}
}
return 0, fmt.Errorf("no available ports in range %d-%d", m.startPort, m.endPort)
}
func (m *Manager) isPortAllocatedInternal(port int) bool {
for _, p := range m.allocations {
if p == port {
return true
}
}
return false
}
func (m *Manager) checkPortAvailable(port int) error {
if port < m.startPort || port > m.endPort {
return fmt.Errorf("port %d is out of allowed range %d-%d", port, m.startPort, m.endPort)
}
if m.isPortAllocatedInternal(port) {
return fmt.Errorf("port %d is internally allocated", port)
}
addr := fmt.Sprintf("localhost:%d", port)
conn, err := net.Dial("tcp", addr)
if err != nil {
return nil
}
_ = conn.Close()
return fmt.Errorf("port %d is already in use", port)
}

3159
cli/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

14
cli/Cargo.toml Normal file
View File

@@ -0,0 +1,14 @@
[package]
name = "cli"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.100"
color-eyre = "0.6.5"
crossterm = "0.29.0"
ratatui = "0.30.0"
reqwest = { version = "0.13.1", features = ["json"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
tokio = { version = "1.49.0", features = ["full"] }

209
cli/src/main.rs Normal file
View File

@@ -0,0 +1,209 @@
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame, Terminal,
};
use serde::Deserialize;
use std::{io, time::Duration};
#[derive(Debug, Deserialize, Clone)]
struct Project {
#[serde(rename = "ID")]
id: u32,
name: String,
repo_url: String,
port: u32,
deployments: Option<Vec<Deployment>>,
}
#[derive(Debug, Deserialize, Clone)]
struct Deployment {
#[serde(rename = "ID")]
id: u32,
status: String,
}
#[derive(Debug, Deserialize, Clone)]
struct ProjectCorrected {
#[serde(rename = "ID")]
id: u32,
name: String,
repo_url: String,
port: u32,
deployments: Option<Vec<Deployment>>,
}
struct App {
projects: Vec<ProjectCorrected>,
state: ListState,
message: String,
}
impl App {
fn new() -> App {
let mut state = ListState::default();
state.select(Some(0));
App {
projects: vec![],
state,
message: "Fetching...".to_string(),
}
}
async fn fetch_data(&mut self) {
match reqwest::get("http://localhost:8080/api/projects").await {
Ok(resp) => {
if resp.status().is_success() {
match resp.json::<Vec<ProjectCorrected>>().await {
Ok(projects) => {
self.projects = projects;
self.message = format!("Loaded {} projects", self.projects.len());
}
Err(e) => self.message = format!("Parse error: {}", e),
}
} else {
self.message = format!("Error: {}", resp.status());
}
}
Err(e) => self.message = format!("Req error: {}", e),
}
}
fn next(&mut self) {
if self.projects.is_empty() {
return;
}
let i = match self.state.selected() {
Some(i) => {
if i >= self.projects.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.state.select(Some(i));
}
fn previous(&mut self) {
if self.projects.is_empty() {
return;
}
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.projects.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.state.select(Some(i));
}
}
#[tokio::main]
async fn main() -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
app.fetch_data().await;
let res = run_app(&mut terminal, app).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{:?}", err)
}
Ok(())
}
async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, mut app: App) -> Result<()> {
loop {
terminal.draw(|f| ui(f, &mut app))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => return Ok(()),
KeyCode::Char('r') => app.fetch_data().await,
KeyCode::Down => app.next(),
KeyCode::Up => app.previous(),
_ => {}
}
}
}
}
}
fn ui(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(f.area());
let header_text = format!("Clickploy CLI - {} (Press 'r' to refresh, 'q' to quit)", app.message);
let header = Paragraph::new(header_text)
.block(Block::default().borders(Borders::ALL).title("Info"));
f.render_widget(header, chunks[0]);
let items: Vec<ListItem> = app
.projects
.iter()
.map(|p| {
let status = if let Some(deps) = &p.deployments {
if let Some(first) = deps.first() {
first.status.clone()
} else {
"unknown".to_string()
}
} else {
"unknown".to_string()
};
let color = match status.as_str() {
"live" | "success" => Color::Green,
"building" => Color::Yellow,
"failed" => Color::Red,
_ => Color::White,
};
let display_name = format!("{} - {}", p.name, status);
ListItem::new(display_name).style(Style::default().fg(color))
})
.collect();
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title("Projects"))
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(">> ");
f.render_stateful_widget(list, chunks[1], &mut app.state);
}

34
docker-compose.yml Normal file
View File

@@ -0,0 +1,34 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: clickploy-backend
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- clickploy-data:/tmp/paas-builds # Persist builds if needed
environment:
- GIN_MODE=release
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: clickploy-frontend
ports:
- "5173:4173"
environment:
- PUBLIC_API_URL=http://localhost:8080 # We usually need this client-side.
# Note: Since frontend is client-side rendered mostly, localhost:8080 refers to user's browser accessing backend
# If SSR calls backend, it needs http://backend:8080.
depends_on:
- backend
restart: unless-stopped
volumes:
clickploy-data:

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM oven/bun:1-alpine as base
WORKDIR /app
# Install dependencies
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
# Copy source
COPY . .
# Build
RUN bun run build
# Expose Preview Port
EXPOSE 4173
# Run
CMD ["bun", "run", "preview", "--host"]

21
frontend/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Clickploy Frontend
The web dashboard for Clickploy, built with SvelteKit.
## Overview
A modern, responsive dashboard to manage your applications, monitor deployments, and view logs in real-time.
## Tech Stack
- **Framework**: SvelteKit
- **Styling**: Tailwind CSS
- **UI Components**: Shadcn-Svelte + Lucide Icons
- **State Management**: Svelte 5 Runes (`$state`, `$derived`)
## Features
- **Project List**: View all deployed applications.
- **Real-time Logs**: Watch builds and runtime logs live.
- **Redeploy**: Trigger manual redeployments.
- **Environment Variables**: Manage runtime configuration.
- **Responsive Design**: Works on desktop and mobile.

16
frontend/components.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/routes/layout.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"
}

37
frontend/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev --host",
"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"
},
"devDependencies": {
"@internationalized/date": "^3.10.1",
"@lucide/svelte": "^0.561.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"bits-ui": "^2.15.5",
"clsx": "^2.1.1",
"svelte": "^5.49.1",
"svelte-check": "^4.3.6",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"svelte-sonner": "^1.0.7"
}
}

13
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// 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 {};

11
frontend/src/app.html Normal file
View File

@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en" class="dark">
<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>

230
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,230 @@
import { toast } from "svelte-sonner";
import { get } from 'svelte/store';
import { token } from './auth';
const API_BASE = "http://localhost:8080";
export interface DeployResponse {
status: string;
app_name: string;
port: number;
url: string;
message: string;
}
export interface AuthResponse {
token: string;
user: any;
}
export interface EnvVar {
key: string;
value: string;
}
export interface Project {
ID: number;
name: string;
repo_url: string;
port: number;
deployments: any[];
env_vars: EnvVar[];
webhook_secret: string;
git_token?: string;
runtime?: string;
}
export async function getProject(id: string): Promise<Project | null> {
try {
return await fetchWithAuth(`/api/projects/${id}`);
} catch (e: any) {
console.error(e);
return null;
}
}
async function fetchWithAuth(url: string, options: RequestInit = {}) {
const t = get(token);
const headers = {
"Content-Type": "application/json",
...options.headers,
...(t ? { Authorization: `Bearer ${t}` } : {}),
};
const res = await fetch(`${API_BASE}${url}`, { ...options, headers });
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Request failed");
}
return res.json();
}
export async function loginUser(email: string, password: string): Promise<AuthResponse | null> {
try {
return await fetchWithAuth("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}
export async function registerUser(email: string, password: string, name: string): Promise<AuthResponse | null> {
try {
return await fetchWithAuth("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password, name }),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}
export async function createProject(
repo: string,
name: string,
port?: number,
envVars?: Record<string, string>,
gitToken?: string,
buildCommand?: string,
startCommand?: string,
installCommand?: string,
runtime?: string
): Promise<Project | null> {
try {
return await fetchWithAuth("/api/projects", {
method: "POST",
body: JSON.stringify({
repo,
name,
port,
env_vars: envVars,
git_token: gitToken,
build_command: buildCommand,
start_command: startCommand,
install_command: installCommand,
runtime: runtime,
}),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}
export async function updateProjectEnv(id: string, envVars: Record<string, string>): Promise<boolean> {
try {
await fetchWithAuth(`/api/projects/${id}/env`, {
method: "PUT",
body: JSON.stringify({ env_vars: envVars }),
});
return true;
} catch (e: any) {
toast.error(e.message);
return false;
}
}
export async function redeployProject(id: string): Promise<boolean> {
try {
await fetchWithAuth(`/api/projects/${id}/redeploy`, {
method: "POST",
});
return true;
} catch (e: any) {
toast.error(e.message);
return false;
}
}
export async function listProjects(): Promise<Project[] | null> {
try {
return await fetchWithAuth("/api/projects");
} catch (e: any) {
console.error(e);
return null;
}
}
export async function deployApp(repo: string, name: string, port?: number): Promise<DeployResponse | null> {
return createProject(repo, name, port) as any;
}
export async function listActivity(): Promise<any[] | null> {
try {
return await fetchWithAuth("/api/activity");
} catch (e: any) {
console.error(e);
return null;
}
}
export async function updateProfile(name: string, email: string) {
try {
return await fetchWithAuth("/api/user/profile", {
method: "PUT",
body: JSON.stringify({ name, email }),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}
export async function getSystemStatus() {
try {
const res = await fetch(`${API_BASE}/api/system/status`);
if (res.ok) {
return await res.json();
}
return null;
} catch (e: any) {
console.error(e);
return null;
}
}
export async function updatePassword(oldPassword: string, newPassword: string) {
try {
return await fetchWithAuth("/api/user/password", {
method: "PUT",
body: JSON.stringify({ old_password: oldPassword, new_password: newPassword }),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}
export async function getStorageStats() {
try {
return await fetchWithAuth("/api/storage/stats");
} catch (e: any) {
console.error(e);
return null;
}
}
export async function listDatabases() {
try {
return await fetchWithAuth("/api/storage/databases");
} catch (e: any) {
console.error(e);
return [];
}
}
export async function createDatabase(name: string, type: string = "sqlite") {
try {
return await fetchWithAuth("/api/storage/databases", {
method: "POST",
body: JSON.stringify({ name, type }),
});
} catch (e: any) {
toast.error(e.message);
return null;
}
}

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

33
frontend/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,33 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface User {
ID: number;
email: string;
name: string;
avatar: string;
}
const storedUser = browser ? localStorage.getItem('user') : null;
const storedToken = browser ? localStorage.getItem('token') : null;
export const user = writable<User | null>(storedUser ? JSON.parse(storedUser) : null);
export const token = writable<string | null>(storedToken);
export function login(userData: User, apiToken: string) {
user.set(userData);
token.set(apiToken);
if (browser) {
localStorage.setItem('user', JSON.stringify(userData));
localStorage.setItem('token', apiToken);
}
}
export function logout() {
user.set(null);
token.set(null);
if (browser) {
localStorage.removeItem('user');
localStorage.removeItem('token');
}
}

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import {
Github,
Twitter,
CheckCircle2,
AlertCircle,
Terminal,
} from "@lucide/svelte";
import { onMount } from "svelte";
import { getSystemStatus } from "$lib/api";
let status = $state("Checking...");
let version = $state("");
let isNormal = $state(true);
onMount(async () => {
const res = await getSystemStatus();
if (res) {
status = res.status;
version = res.version;
isNormal = true;
} else {
status = "System Unavailable";
isNormal = false;
}
});
</script>
<footer
class="border-t py-6 md:py-0 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60"
>
<div
class="container flex flex-col items-center justify-center gap-6 md:h-16 md:flex-row px-4"
>
<div class="flex flex-col items-center gap-6 md:flex-row">
<a href="/" class="flex items-center gap-2 text-foreground font-semibold">
<div class="bg-foreground text-background rounded-full p-1">
<Terminal class="h-3 w-3" />
</div>
</a>
<nav
class="flex flex-wrap justify-center gap-4 text-sm text-muted-foreground"
>
<a href="/" class="hover:text-foreground transition-colors">Home</a>
<a href="/" class="hover:text-foreground transition-colors">Docs</a>
<a
href="https://github.com/SirBlobby/Clickploy"
target="_blank"
rel="noreferrer"
class="hover:text-foreground transition-colors">GitHub</a
>
<a href="/" class="hover:text-foreground transition-colors">Support</a>
<a href="/" class="hover:text-foreground transition-colors">Legal</a>
</nav>
</div>
<div class="h-4 w-px bg-border hidden md:block"></div>
<div class="flex items-center gap-2 text-sm font-medium">
<div
class={`h-2.5 w-2.5 rounded-full ${isNormal ? "bg-blue-500" : "bg-red-500"} animate-pulse`}
></div>
<span class={isNormal ? "text-blue-500" : "text-red-500"}>
{isNormal ? "All systems normal." : status}
</span>
</div>
</div>
</footer>

View File

@@ -0,0 +1,183 @@
<script lang="ts">
import { user, logout } from "$lib/auth";
import { Button } from "$lib/components/ui/button";
import {
Terminal,
Settings,
LogOut,
Activity,
LayoutDashboard,
Menu,
X,
Rocket,
Network,
Database,
} from "@lucide/svelte";
import { page } from "$app/stores";
import * as Sheet from "$lib/components/ui/sheet";
let mobileOpen = $state(false);
function isActive(path: string) {
return $page.url.pathname === path ? "bg-secondary" : "hover:bg-accent/50";
}
</script>
<header
class="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur-sm"
>
<div class="container mx-auto flex h-14 items-center justify-between px-4">
<div class="flex items-center gap-6">
<a href="/" class="flex items-center gap-2 font-bold text-xl">
<Terminal class="h-6 w-6" />
<span>Clickploy</span>
</a>
{#if $user}
<nav class="hidden md:flex items-center gap-2">
<Button variant="ghost" size="sm" href="/" class={isActive("/")}>
<LayoutDashboard class="mr-2 h-4 w-4" /> Overview
</Button>
<Button
variant="ghost"
size="sm"
href="/deployments"
class={isActive("/deployments")}
>
<Rocket class="mr-2 h-4 w-4" /> Deployments
</Button>
<Button
variant="ghost"
size="sm"
href="/network"
class={isActive("/network")}
>
<Network class="mr-2 h-4 w-4" /> Network
</Button>
<Button
variant="ghost"
size="sm"
href="/activity"
class={isActive("/activity")}
>
<Activity class="mr-2 h-4 w-4" /> Activity
</Button>
<Button
variant="ghost"
size="sm"
href="/storage"
class={isActive("/storage")}
>
<Database class="mr-2 h-4 w-4" /> Storage
</Button>
<Button
variant="ghost"
size="sm"
href="/settings"
class={isActive("/settings")}
>
<Settings class="mr-2 h-4 w-4" /> Settings
</Button>
</nav>
{/if}
</div>
{#if $user}
<div class="flex items-center gap-4">
<div class="hidden md:flex items-center gap-2">
<div
class="h-8 w-8 rounded-full bg-linear-to-tr from-primary to-purple-500"
></div>
<span class="text-sm font-medium">{$user.name}</span>
</div>
<Button
variant="ghost"
size="icon"
onclick={logout}
title="Log out"
class="hidden md:flex"
>
<LogOut class="h-4 w-4" />
</Button>
<div class="md:hidden">
<Sheet.Root bind:open={mobileOpen}>
<Sheet.Trigger>
<Button variant="ghost" size="icon">
<Menu class="h-5 w-5" />
</Button>
</Sheet.Trigger>
<Sheet.Content side="right">
<Sheet.Header>
<Sheet.Title>Menu</Sheet.Title>
</Sheet.Header>
<div class="grid gap-4 py-4">
<a
href="/"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<LayoutDashboard class="h-5 w-5" /> Overview
</a>
<a
href="/deployments"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Rocket class="h-5 w-5" /> Deployments
</a>
<a
href="/network"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Network class="h-5 w-5" /> Network
</a>
<a
href="/activity"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Activity class="h-5 w-5" /> Activity
</a>
<a
href="/storage"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Database class="h-5 w-5" /> Storage
</a>
<a
href="/settings"
class="flex items-center gap-2 py-2 text-lg font-medium"
onclick={() => (mobileOpen = false)}
>
<Settings class="h-5 w-5" /> Settings
</a>
<div class="border-t my-2"></div>
<div class="flex items-center gap-2 py-2">
<div
class="h-6 w-6 rounded-full bg-linear-to-tr from-primary to-purple-500"
></div>
<span class="font-medium">{$user.name}</span>
</div>
<Button
variant="ghost"
class="justify-start gap-2 px-0"
onclick={logout}
>
<LogOut class="h-5 w-5" /> Log out
</Button>
</div>
</Sheet.Content>
</Sheet.Root>
</div>
</div>
{:else}
<div class="flex gap-2">
<Button variant="ghost" size="sm" href="/login">Login</Button>
<Button size="sm" href="/register">Get Started</Button>
</div>
{/if}
</div>
</header>

View File

@@ -0,0 +1,23 @@
<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="alert-description"
class={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -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="alert-title"
class={cn("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const alertVariants = tv({
base: "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
});
export type AlertVariant = VariantProps<typeof alertVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,14 @@
import Root from "./alert.svelte";
import Description from "./alert-description.svelte";
import Title from "./alert-title.svelte";
export { alertVariants, type AlertVariant } from "./alert.svelte";
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle,
};

View File

@@ -0,0 +1,50 @@
<script lang="ts" module>
import { type VariantProps, tv } from "tailwind-variants";
export const badgeVariants = 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 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
variants: {
variant: {
default:
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
destructive:
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
});
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
href,
class: className,
variant = "default",
children,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
variant?: BadgeVariant;
} = $props();
</script>
<svelte:element
this={href ? "a" : "span"}
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{@render children?.()}
</svelte:element>

View File

@@ -0,0 +1,2 @@
export { default as Badge } from "./badge.svelte";
export { badgeVariants, type BadgeVariant } from "./badge.svelte";

View File

@@ -0,0 +1,82 @@
<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 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
destructive:
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
outline:
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
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",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
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}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("flex items-center px-6 [.border-t]:pt-6", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -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 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -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("leading-none font-semibold", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,23 @@
<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(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

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

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
</script>
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
</script>
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Collapsible as CollapsiblePrimitive } from "bits-ui";
let {
ref = $bindable(null),
open = $bindable(false),
...restProps
}: CollapsiblePrimitive.RootProps = $props();
</script>
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />

View File

@@ -0,0 +1,13 @@
import Root from "./collapsible.svelte";
import Trigger from "./collapsible-trigger.svelte";
import Content from "./collapsible-content.svelte";
export {
Root,
Content,
Trigger,
//
Root as Collapsible,
Content as CollapsibleContent,
Trigger as CollapsibleTrigger,
};

View File

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

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
import DialogPortal from "./dialog-portal.svelte";
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";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...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 top-[50%] left-[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 absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>

View File

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

View File

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

View File

@@ -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-start", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

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

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />

View File

@@ -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 leading-none font-semibold", className)}
{...restProps}
/>

View File

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

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,34 @@
import Root from "./dialog.svelte";
import Portal from "./dialog-portal.svelte";
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";
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,
};

View File

@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};

View File

@@ -0,0 +1,52 @@
<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,
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
</script>
{#if type === "file"}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
"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={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none 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}

View File

@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};

View File

@@ -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 items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
import Root from "./progress.svelte";
export {
Root,
//
Root as Progress,
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { Progress as ProgressPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
max = 100,
value,
...restProps
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
</script>
<ProgressPrimitive.Root
bind:ref
data-slot="progress"
class={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)}
{value}
{max}
{...restProps}
>
<div
data-slot="progress-indicator"
class="bg-primary h-full w-full flex-1 transition-all"
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
></div>
</ProgressPrimitive.Root>

View File

@@ -0,0 +1,37 @@
import Root from "./select.svelte";
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";
import Portal from "./select-portal.svelte";
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
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,
Portal as SelectPortal,
};

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import SelectPortal from "./select-portal.svelte";
import SelectScrollUpButton from "./select-scroll-up-button.svelte";
import SelectScrollDownButton from "./select-scroll-down-button.svelte";
import { cn, type WithoutChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import type { WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
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-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto 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) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1"
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>

View File

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

View File

@@ -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 bind:ref data-slot="select-group" {...restProps} />

View File

@@ -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 relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-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>

View File

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

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />

View File

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

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from "@lucide/svelte/icons/chevron-up";
import { Select as SelectPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from "bits-ui";
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...restProps}
/>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
import { cn, type WithoutChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
size = "default",
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: "sm" | "default";
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />

View File

@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
"data-slot": dataSlot = "separator",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:min-h-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,34 @@
import Root from "./sheet.svelte";
import Portal from "./sheet-portal.svelte";
import Trigger from "./sheet-trigger.svelte";
import Close from "./sheet-close.svelte";
import Overlay from "./sheet-overlay.svelte";
import Content from "./sheet-content.svelte";
import Header from "./sheet-header.svelte";
import Footer from "./sheet-footer.svelte";
import Title from "./sheet-title.svelte";
import Description from "./sheet-description.svelte";
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />

View File

@@ -0,0 +1,60 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sheetVariants = tv({
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
variants: {
side: {
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
left: "data-[state=closed]:slide-out-to-start data-[state=open]:slide-in-from-start inset-y-0 start-0 h-full w-3/4 border-e sm:max-w-sm",
right: "data-[state=closed]:slide-out-to-end data-[state=open]:slide-in-from-end inset-y-0 end-0 h-full w-3/4 border-s sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
});
export type Side = VariantProps<typeof sheetVariants>["side"];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import SheetPortal from "./sheet-portal.svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
side = "right",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SheetPortal>>;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPortal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="ring-offset-background focus-visible:ring-ring absolute end-4 top-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -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="sheet-footer"
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -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="sheet-header"
class={cn("flex flex-col gap-1.5 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-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}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ...restProps }: SheetPrimitive.PortalProps = $props();
</script>
<SheetPrimitive.Portal {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
data-slot="sheet-title"
class={cn("text-foreground font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
</script>
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { open = $bindable(false), ...restProps }: SheetPrimitive.RootProps = $props();
</script>
<SheetPrimitive.Root bind:open {...restProps} />

View File

@@ -0,0 +1,28 @@
import Root from "./table.svelte";
import Body from "./table-body.svelte";
import Caption from "./table-caption.svelte";
import Cell from "./table-cell.svelte";
import Footer from "./table-footer.svelte";
import Head from "./table-head.svelte";
import Header from "./table-header.svelte";
import Row from "./table-row.svelte";
export {
Root,
Body,
Caption,
Cell,
Footer,
Head,
Header,
Row,
//
Root as Table,
Body as TableBody,
Caption as TableCaption,
Cell as TableCell,
Footer as TableFooter,
Head as TableHead,
Header as TableHeader,
Row as TableRow,
};

View File

@@ -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<HTMLTableSectionElement>> = $props();
</script>
<tbody
bind:this={ref}
data-slot="table-body"
class={cn("[&_tr:last-child]:border-0", className)}
{...restProps}
>
{@render children?.()}
</tbody>

Some files were not shown because too many files have changed in this diff Show More