Initial commit
This commit is contained in:
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal 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
24
README.md
Normal 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
28
backend/Dockerfile
Normal 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
28
backend/README.md
Normal 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.
|
||||
51
backend/cmd/server/main.go
Normal file
51
backend/cmd/server/main.go
Normal 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
66
backend/go.mod
Normal 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
178
backend/go.sum
Normal 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=
|
||||
92
backend/internal/api/api.go
Normal file
92
backend/internal/api/api.go
Normal 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",
|
||||
})
|
||||
}
|
||||
147
backend/internal/api/auth.go
Normal file
147
backend/internal/api/auth.go
Normal 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"})
|
||||
}
|
||||
33
backend/internal/api/middleware.go
Normal file
33
backend/internal/api/middleware.go
Normal 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()
|
||||
}
|
||||
}
|
||||
283
backend/internal/api/project.go
Normal file
283
backend/internal/api/project.go
Normal 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)
|
||||
}
|
||||
98
backend/internal/api/storage.go
Normal file
98
backend/internal/api/storage.go
Normal 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)
|
||||
}
|
||||
111
backend/internal/api/stream.go
Normal file
111
backend/internal/api/stream.go
Normal 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)
|
||||
}
|
||||
89
backend/internal/api/webhook.go
Normal file
89
backend/internal/api/webhook.go
Normal 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})
|
||||
}
|
||||
54
backend/internal/auth/utils.go
Normal file
54
backend/internal/auth/utils.go
Normal 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
|
||||
}
|
||||
138
backend/internal/builder/builder.go
Normal file
138
backend/internal/builder/builder.go
Normal 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
33
backend/internal/db/db.go
Normal 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")
|
||||
}
|
||||
65
backend/internal/deployer/deployer.go
Normal file
65
backend/internal/deployer/deployer.go
Normal 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})
|
||||
}
|
||||
56
backend/internal/models/models.go
Normal file
56
backend/internal/models/models.go
Normal 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"`
|
||||
}
|
||||
80
backend/internal/ports/ports.go
Normal file
80
backend/internal/ports/ports.go
Normal 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
3159
cli/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
cli/Cargo.toml
Normal file
14
cli/Cargo.toml
Normal 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
209
cli/src/main.rs
Normal 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
34
docker-compose.yml
Normal 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
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
18
frontend/Dockerfile
Normal file
18
frontend/Dockerfile
Normal 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
21
frontend/README.md
Normal 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
16
frontend/components.json
Normal 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
37
frontend/package.json
Normal 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
13
frontend/src/app.d.ts
vendored
Normal 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
11
frontend/src/app.html
Normal 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
230
frontend/src/lib/api.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal 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
33
frontend/src/lib/auth.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
68
frontend/src/lib/components/Footer.svelte
Normal file
68
frontend/src/lib/components/Footer.svelte
Normal 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>
|
||||
183
frontend/src/lib/components/Navbar.svelte
Normal file
183
frontend/src/lib/components/Navbar.svelte
Normal 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>
|
||||
@@ -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>
|
||||
20
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
20
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal 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>
|
||||
44
frontend/src/lib/components/ui/alert/alert.svelte
Normal file
44
frontend/src/lib/components/ui/alert/alert.svelte
Normal 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>
|
||||
14
frontend/src/lib/components/ui/alert/index.ts
Normal file
14
frontend/src/lib/components/ui/alert/index.ts
Normal 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,
|
||||
};
|
||||
50
frontend/src/lib/components/ui/badge/badge.svelte
Normal file
50
frontend/src/lib/components/ui/badge/badge.svelte
Normal 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>
|
||||
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
82
frontend/src/lib/components/ui/button/button.svelte
Normal file
82
frontend/src/lib/components/ui/button/button.svelte
Normal 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}
|
||||
17
frontend/src/lib/components/ui/button/index.ts
Normal file
17
frontend/src/lib/components/ui/button/index.ts
Normal 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,
|
||||
};
|
||||
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal 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>
|
||||
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal file
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal 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>
|
||||
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal file
23
frontend/src/lib/components/ui/card/card-header.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-title.svelte
Normal 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>
|
||||
23
frontend/src/lib/components/ui/card/card.svelte
Normal file
23
frontend/src/lib/components/ui/card/card.svelte
Normal 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>
|
||||
25
frontend/src/lib/components/ui/card/index.ts
Normal file
25
frontend/src/lib/components/ui/card/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
@@ -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} />
|
||||
13
frontend/src/lib/components/ui/collapsible/index.ts
Normal file
13
frontend/src/lib/components/ui/collapsible/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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} />
|
||||
45
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal file
45
frontend/src/lib/components/ui/dialog/dialog-content.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-footer.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-header.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal file
20
frontend/src/lib/components/ui/dialog/dialog-overlay.svelte
Normal 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}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as DialogPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: DialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<DialogPrimitive.Portal {...restProps} />
|
||||
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal file
17
frontend/src/lib/components/ui/dialog/dialog-title.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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} />
|
||||
7
frontend/src/lib/components/ui/dialog/dialog.svelte
Normal file
7
frontend/src/lib/components/ui/dialog/dialog.svelte
Normal 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} />
|
||||
34
frontend/src/lib/components/ui/dialog/index.ts
Normal file
34
frontend/src/lib/components/ui/dialog/index.ts
Normal 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,
|
||||
};
|
||||
7
frontend/src/lib/components/ui/input/index.ts
Normal file
7
frontend/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
52
frontend/src/lib/components/ui/input/input.svelte
Normal file
52
frontend/src/lib/components/ui/input/input.svelte
Normal 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}
|
||||
7
frontend/src/lib/components/ui/label/index.ts
Normal file
7
frontend/src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
20
frontend/src/lib/components/ui/label/label.svelte
Normal file
20
frontend/src/lib/components/ui/label/label.svelte
Normal 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}
|
||||
/>
|
||||
7
frontend/src/lib/components/ui/progress/index.ts
Normal file
7
frontend/src/lib/components/ui/progress/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./progress.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Progress,
|
||||
};
|
||||
27
frontend/src/lib/components/ui/progress/progress.svelte
Normal file
27
frontend/src/lib/components/ui/progress/progress.svelte
Normal 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>
|
||||
37
frontend/src/lib/components/ui/select/index.ts
Normal file
37
frontend/src/lib/components/ui/select/index.ts
Normal 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,
|
||||
};
|
||||
45
frontend/src/lib/components/ui/select/select-content.svelte
Normal file
45
frontend/src/lib/components/ui/select/select-content.svelte
Normal 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>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import type { ComponentProps } from "svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.GroupHeading
|
||||
bind:ref
|
||||
data-slot="select-group-heading"
|
||||
class={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</SelectPrimitive.GroupHeading>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />
|
||||
38
frontend/src/lib/components/ui/select/select-item.svelte
Normal file
38
frontend/src/lib/components/ui/select/select-item.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/select/select-label.svelte
Normal file
20
frontend/src/lib/components/ui/select/select-label.svelte
Normal 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>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Select as SelectPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: SelectPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<SelectPrimitive.Portal {...restProps} />
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
29
frontend/src/lib/components/ui/select/select-trigger.svelte
Normal file
29
frontend/src/lib/components/ui/select/select-trigger.svelte
Normal 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>
|
||||
11
frontend/src/lib/components/ui/select/select.svelte
Normal file
11
frontend/src/lib/components/ui/select/select.svelte
Normal 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} />
|
||||
7
frontend/src/lib/components/ui/separator/index.ts
Normal file
7
frontend/src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
21
frontend/src/lib/components/ui/separator/separator.svelte
Normal file
21
frontend/src/lib/components/ui/separator/separator.svelte
Normal 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}
|
||||
/>
|
||||
34
frontend/src/lib/components/ui/sheet/index.ts
Normal file
34
frontend/src/lib/components/ui/sheet/index.ts
Normal 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,
|
||||
};
|
||||
7
frontend/src/lib/components/ui/sheet/sheet-close.svelte
Normal file
7
frontend/src/lib/components/ui/sheet/sheet-close.svelte
Normal 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} />
|
||||
60
frontend/src/lib/components/ui/sheet/sheet-content.svelte
Normal file
60
frontend/src/lib/components/ui/sheet/sheet-content.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
20
frontend/src/lib/components/ui/sheet/sheet-footer.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-footer.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/sheet/sheet-header.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-header.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/sheet/sheet-overlay.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-overlay.svelte
Normal 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}
|
||||
/>
|
||||
7
frontend/src/lib/components/ui/sheet/sheet-portal.svelte
Normal file
7
frontend/src/lib/components/ui/sheet/sheet-portal.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Dialog as SheetPrimitive } from "bits-ui";
|
||||
|
||||
let { ...restProps }: SheetPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<SheetPrimitive.Portal {...restProps} />
|
||||
17
frontend/src/lib/components/ui/sheet/sheet-title.svelte
Normal file
17
frontend/src/lib/components/ui/sheet/sheet-title.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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} />
|
||||
7
frontend/src/lib/components/ui/sheet/sheet.svelte
Normal file
7
frontend/src/lib/components/ui/sheet/sheet.svelte
Normal 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} />
|
||||
28
frontend/src/lib/components/ui/table/index.ts
Normal file
28
frontend/src/lib/components/ui/table/index.ts
Normal 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,
|
||||
};
|
||||
20
frontend/src/lib/components/ui/table/table-body.svelte
Normal file
20
frontend/src/lib/components/ui/table/table-body.svelte
Normal 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
Reference in New Issue
Block a user