commit b0b85f4d3a46f63ed52f1979fbdaff718101bbf0 Author: evan Date: Thu Apr 16 15:11:20 2026 +0000 Initial fullstack project setup with Next.js 15, Gin, PostgreSQL and Docker Compose - Frontend: Next.js 15 (App Router), Auth.js v5, shadcn/ui, MagicUI - Backend: Go + Gin + GORM with layered architecture - Auth: Local credentials login with optional Keycloak OAuth binding - Admin: RBAC user management for admin role - Dev: Docker Compose with hot reload for both frontend and backend - Docker: 3-service orchestration (frontend, backend, postgres) Co-Authored-By: Claude Opus 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a6e091e --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Database +POSTGRES_USER=evan +POSTGRES_PASSWORD=evanpass +POSTGRES_DB=evanpage +DATABASE_URL=postgres://evan:evanpass@db:5432/evanpage?sslmode=disable + +# Backend +SERVER_PORT=8080 +SERVER_API_URL=http://backend:8080 + +# Auth.js / Frontend +AUTH_SECRET=your-random-secret-min-32-chars-long +NEXT_PUBLIC_API_URL=http://localhost:8080 + +# Keycloak +AUTH_KEYCLOAK_ID=evanpage-frontend +AUTH_KEYCLOAK_SECRET=your-keycloak-client-secret +AUTH_KEYCLOAK_ISSUER=https://keycloak.liukersun.com/realms/evan diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7dccb90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.claude/ +openspec/ +frontend/node_modules/ +backend/tmp/ +backend/main +.env diff --git a/backend/.air.toml b/backend/.air.toml new file mode 100644 index 0000000..94bc9ea --- /dev/null +++ b/backend/.air.toml @@ -0,0 +1,34 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/api/main.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + kill_delay = "0s" + log = "build-errors.log" + send_interrupt = false + stop_on_root = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + time = false + +[misc] + clean_on_exit = false diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..867d62d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,27 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/api/main.go + +FROM golang:1.25-alpine AS dev + +RUN go install github.com/air-verse/air@latest + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +EXPOSE 8080 +CMD ["air", "-c", ".air.toml"] + +FROM alpine:latest AS production + +WORKDIR /app +COPY --from=builder /app/server . + +EXPOSE 8080 +CMD ["./server"] diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..58e3c84 --- /dev/null +++ b/backend/cmd/api/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "log" + + "evanpage-backend/internal/config" + "evanpage-backend/internal/db" + "evanpage-backend/internal/router" +) + +func main() { + cfg := config.Load() + db.Init(cfg) + + r := router.Setup(cfg) + + addr := ":" + cfg.ServerPort + log.Printf("server starting on %s", addr) + if err := r.Run(addr); err != nil { + log.Fatalf("server failed: %v", err) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..5dc97ef --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,46 @@ +module evanpage-backend + +go 1.25.0 + +require ( + github.com/bytedance/gopkg v0.1.4 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.1 // indirect + github.com/gin-gonic/gin v1.12.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.30.2 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect + golang.org/x/arch v0.26.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gorm.io/driver/postgres v1.6.0 // indirect + gorm.io/gorm v1.31.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..5b6a9d8 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,98 @@ +github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= +github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= +github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= +github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= +github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= +github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= +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.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +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/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.mongodb.org/mongo-driver/v2 v2.5.1 h1:j2U/Qp+wvueSpqitLCSZPT/+ZpVc1xzuwdHWwl7d8ro= +go.mongodb.org/mongo-driver/v2 v2.5.1/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4= +golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..b9dd952 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,38 @@ +package config + +import ( + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + DatabaseURL string + ServerPort string + ServerAPIURL string + AuthSecret string + KeycloakIssuer string + KeycloakID string + KeycloakSecret string +} + +func Load() *Config { + _ = godotenv.Load() + + return &Config{ + DatabaseURL: getEnv("DATABASE_URL", "postgres://evan:evanpass@localhost:5432/evanpage?sslmode=disable"), + ServerPort: getEnv("SERVER_PORT", "8080"), + ServerAPIURL: getEnv("SERVER_API_URL", "http://localhost:8080"), + AuthSecret: getEnv("AUTH_SECRET", ""), + KeycloakIssuer: getEnv("AUTH_KEYCLOAK_ISSUER", ""), + KeycloakID: getEnv("AUTH_KEYCLOAK_ID", ""), + KeycloakSecret: getEnv("AUTH_KEYCLOAK_SECRET", ""), + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..d178bf5 --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,27 @@ +package db + +import ( + "log" + + "evanpage-backend/internal/config" + "evanpage-backend/internal/domain" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +var DB *gorm.DB + +func Init(cfg *config.Config) *gorm.DB { + var err error + DB, err = gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{}) + if err != nil { + log.Fatalf("failed to connect database: %v", err) + } + + if err := DB.AutoMigrate(&domain.User{}); err != nil { + log.Fatalf("failed to migrate database: %v", err) + } + + log.Println("database connected and migrated") + return DB +} diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go new file mode 100644 index 0000000..60c3846 --- /dev/null +++ b/backend/internal/domain/user.go @@ -0,0 +1,15 @@ +package domain + +import "time" + +type User struct { + ID uint `gorm:"primarykey" json:"id"` + Username string `gorm:"uniqueIndex;not null" json:"username"` + Email string `gorm:"uniqueIndex;not null" json:"email"` + PasswordHash string `gorm:"not null" json:"-"` + Role string `gorm:"default:'user';not null" json:"role"` + KeycloakID *string `gorm:"uniqueIndex" json:"keycloakId,omitempty"` + KeycloakEmail string `json:"keycloakEmail,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go new file mode 100644 index 0000000..0de090c --- /dev/null +++ b/backend/internal/handler/admin.go @@ -0,0 +1,68 @@ +package handler + +import ( + "net/http" + "strconv" + + "evanpage-backend/internal/service" + "github.com/gin-gonic/gin" +) + +type AdminHandler struct { + userService *service.UserService +} + +func NewAdminHandler(userService *service.UserService) *AdminHandler { + return &AdminHandler{userService: userService} +} + +func (h *AdminHandler) ListUsers(c *gin.Context) { + users, err := h.userService.ListUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"users": users}) +} + +func (h *AdminHandler) CreateUser(c *gin.Context) { + var req struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + Role string `json:"role" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.userService.Register(req.Username, req.Email, req.Password, req.Role) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "role": user.Role, + }) +} + +func (h *AdminHandler) DeleteUser(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + if err := h.userService.DeleteUser(uint(id)); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "user deleted"}) +} diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go new file mode 100644 index 0000000..086b4a3 --- /dev/null +++ b/backend/internal/handler/auth.go @@ -0,0 +1,156 @@ +package handler + +import ( + "net/http" + + "evanpage-backend/internal/service" + "github.com/gin-gonic/gin" +) + +type AuthHandler struct { + userService *service.UserService +} + +func NewAuthHandler(userService *service.UserService) *AuthHandler { + return &AuthHandler{userService: userService} +} + +func (h *AuthHandler) Register(c *gin.Context) { + var req struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.userService.Register(req.Username, req.Email, req.Password, "user") + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "role": user.Role, + }) +} + +func (h *AuthHandler) LocalLogin(c *gin.Context) { + var req struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.userService.ValidateLocalLogin(req.Username, req.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "role": user.Role, + }) +} + +func (h *AuthHandler) LookupBinding(c *gin.Context) { + var req struct { + KeycloakID string `json:"keycloakId" binding:"required"` + Email string `json:"email"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.userService.LookupKeycloakBinding(req.KeycloakID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"bound": false}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "bound": true, + "user": gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "role": user.Role, + }, + }) +} + +func (h *AuthHandler) BindKeycloak(c *gin.Context) { + var req struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + KeycloakID string `json:"keycloakId" binding:"required"` + KeycloakEmail string `json:"keycloakEmail"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.userService.BindKeycloak(req.Username, req.Password, req.KeycloakID, req.KeycloakEmail) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "bound": true, + "user": gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "role": user.Role, + }, + }) +} + +func (h *AuthHandler) InitAdmin(c *gin.Context) { + count, err := h.userService.CountUsers() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + if count > 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "already initialized"}) + return + } + + var req struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + user, err := h.userService.Register(req.Username, req.Email, req.Password, "admin") + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "id": user.ID, + "username": user.Username, + "email": user.Email, + "role": user.Role, + }) +} diff --git a/backend/internal/handler/health.go b/backend/internal/handler/health.go new file mode 100644 index 0000000..bb4ec6a --- /dev/null +++ b/backend/internal/handler/health.go @@ -0,0 +1,32 @@ +package handler + +import ( + "net/http" + "time" + + "gorm.io/gorm" + "github.com/gin-gonic/gin" +) + +type HealthHandler struct { + db *gorm.DB +} + +func NewHealthHandler(db *gorm.DB) *HealthHandler { + return &HealthHandler{db: db} +} + +func (h *HealthHandler) Check(c *gin.Context) { + sqlDB, err := h.db.DB() + if err != nil { + c.String(http.StatusInternalServerError, "DB connection error: "+err.Error()) + return + } + + if err := sqlDB.Ping(); err != nil { + c.String(http.StatusInternalServerError, "DB unreachable: "+err.Error()) + return + } + + c.String(http.StatusOK, "Database connected. Server time: %s", time.Now().Format(time.RFC3339)) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..5be7ca6 --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -0,0 +1,23 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func AuthProxy() gin.HandlerFunc { + return func(c *gin.Context) { + userID := c.GetHeader("X-User-Id") + userRole := c.GetHeader("X-User-Role") + + if userID == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + c.Set("userID", userID) + c.Set("userRole", userRole) + c.Next() + } +} diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go new file mode 100644 index 0000000..7dac0fb --- /dev/null +++ b/backend/internal/middleware/cors.go @@ -0,0 +1,33 @@ +package middleware + +import ( + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +func CORS() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-User-Id, X-User-Role") + c.Writer.Header().Set("Access-Control-Max-Age", "86400") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} + +func Logger() gin.HandlerFunc { + return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + return param.TimeStamp.Format(time.RFC3339) + " " + + param.Method + " " + param.Path + " " + + param.ClientIP + " " + strconv.Itoa(param.StatusCode) + " " + + param.Latency.String() + "\n" + }) +} diff --git a/backend/internal/middleware/rbac.go b/backend/internal/middleware/rbac.go new file mode 100644 index 0000000..4330121 --- /dev/null +++ b/backend/internal/middleware/rbac.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func RequireRole(role string) gin.HandlerFunc { + return func(c *gin.Context) { + userRole := c.GetString("userRole") + if userRole != role { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"}) + return + } + c.Next() + } +} diff --git a/backend/internal/repository/user.go b/backend/internal/repository/user.go new file mode 100644 index 0000000..550a733 --- /dev/null +++ b/backend/internal/repository/user.go @@ -0,0 +1,62 @@ +package repository + +import ( + "evanpage-backend/internal/domain" + "gorm.io/gorm" +) + +type UserRepository struct { + db *gorm.DB +} + +func NewUserRepository(db *gorm.DB) *UserRepository { + return &UserRepository{db: db} +} + +func (r *UserRepository) Create(user *domain.User) error { + return r.db.Create(user).Error +} + +func (r *UserRepository) FindByID(id uint) (*domain.User, error) { + var user domain.User + err := r.db.First(&user, id).Error + return &user, err +} + +func (r *UserRepository) FindByUsername(username string) (*domain.User, error) { + var user domain.User + err := r.db.Where("username = ?", username).First(&user).Error + return &user, err +} + +func (r *UserRepository) FindByEmail(email string) (*domain.User, error) { + var user domain.User + err := r.db.Where("email = ?", email).First(&user).Error + return &user, err +} + +func (r *UserRepository) FindByKeycloakID(keycloakID string) (*domain.User, error) { + var user domain.User + err := r.db.Where("keycloak_id = ?", keycloakID).First(&user).Error + return &user, err +} + +func (r *UserRepository) ListAll() ([]domain.User, error) { + var users []domain.User + err := r.db.Select("id", "username", "email", "role", "keycloak_id", "created_at", "updated_at").Find(&users).Error + return users, err +} + +func (r *UserRepository) Update(user *domain.User) error { + return r.db.Save(user).Error +} + +func (r *UserRepository) Delete(id uint) error { + return r.db.Delete(&domain.User{}, id).Error +} + +func (r *UserRepository) Count() (int64, error) { + var count int64 + err := r.db.Model(&domain.User{}).Count(&count).Error + return count, err +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go new file mode 100644 index 0000000..ff417cc --- /dev/null +++ b/backend/internal/router/router.go @@ -0,0 +1,51 @@ +package router + +import ( + "evanpage-backend/internal/config" + "evanpage-backend/internal/db" + "evanpage-backend/internal/handler" + "evanpage-backend/internal/middleware" + "evanpage-backend/internal/repository" + "evanpage-backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func Setup(cfg *config.Config) *gin.Engine { + r := gin.New() + r.Use(middleware.Logger()) + r.Use(middleware.CORS()) + r.Use(gin.Recovery()) + + userRepo := repository.NewUserRepository(db.DB) + userService := service.NewUserService(userRepo) + + authHandler := handler.NewAuthHandler(userService) + healthHandler := handler.NewHealthHandler(db.DB) + adminHandler := handler.NewAdminHandler(userService) + + // Public routes + r.POST("/api/auth/register", authHandler.Register) + r.POST("/api/auth/local-login", authHandler.LocalLogin) + r.POST("/api/auth/lookup-binding", authHandler.LookupBinding) + r.POST("/api/auth/bind-keycloak", authHandler.BindKeycloak) + r.POST("/api/auth/init", authHandler.InitAdmin) + r.GET("/api/health", healthHandler.Check) + + // Protected routes + api := r.Group("/api") + api.Use(middleware.AuthProxy()) + { + } + + // Admin routes + admin := api.Group("/admin") + admin.Use(middleware.RequireRole("admin")) + { + admin.GET("/users", adminHandler.ListUsers) + admin.POST("/users", adminHandler.CreateUser) + admin.DELETE("/users/:id", adminHandler.DeleteUser) + } + + return r +} diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go new file mode 100644 index 0000000..27ab029 --- /dev/null +++ b/backend/internal/service/user.go @@ -0,0 +1,106 @@ +package service + +import ( + "errors" + + "evanpage-backend/internal/domain" + "evanpage-backend/internal/repository" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type UserService struct { + repo *repository.UserRepository +} + +func NewUserService(repo *repository.UserRepository) *UserService { + return &UserService{repo: repo} +} + +func (s *UserService) Register(username, email, password, role string) (*domain.User, error) { + _, err := s.repo.FindByUsername(username) + if err == nil { + return nil, errors.New("username already exists") + } + _, err = s.repo.FindByEmail(email) + if err == nil { + return nil, errors.New("email already exists") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + if role == "" { + role = "user" + } + + user := &domain.User{ + Username: username, + Email: email, + PasswordHash: string(hash), + Role: role, + } + + if err := s.repo.Create(user); err != nil { + return nil, err + } + return user, nil +} + +func (s *UserService) ValidateLocalLogin(username, password string) (*domain.User, error) { + user, err := s.repo.FindByUsername(username) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("invalid credentials") + } + return nil, err + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, errors.New("invalid credentials") + } + + return user, nil +} + +func (s *UserService) LookupKeycloakBinding(keycloakID string) (*domain.User, error) { + return s.repo.FindByKeycloakID(keycloakID) +} + +func (s *UserService) BindKeycloak(username, password, keycloakID, keycloakEmail string) (*domain.User, error) { + user, err := s.ValidateLocalLogin(username, password) + if err != nil { + return nil, err + } + + if user.KeycloakID != nil && *user.KeycloakID != "" && *user.KeycloakID != keycloakID { + return nil, errors.New("user already bound to another keycloak account") + } + + existing, err := s.repo.FindByKeycloakID(keycloakID) + if err == nil && existing.ID != user.ID { + return nil, errors.New("keycloak account already bound to another user") + } + + user.KeycloakID = &keycloakID + user.KeycloakEmail = keycloakEmail + if err := s.repo.Update(user); err != nil { + return nil, err + } + + return user, nil +} + +func (s *UserService) ListUsers() ([]domain.User, error) { + return s.repo.ListAll() +} + +func (s *UserService) DeleteUser(id uint) error { + return s.repo.Delete(id) +} + +func (s *UserService) CountUsers() (int64, error) { + return s.repo.Count() +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2316396 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,71 @@ +services: + db: + image: postgres:16-alpine + container_name: evanpage-db + environment: + POSTGRES_USER: ${POSTGRES_USER:-evan} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-evanpass} + POSTGRES_DB: ${POSTGRES_DB:-evanpage} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-evan} -d ${POSTGRES_DB:-evanpage}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - evanpage-net + + backend: + build: + context: ./backend + target: dev + container_name: evanpage-backend + environment: + DATABASE_URL: ${DATABASE_URL:-postgres://evan:evanpass@db:5432/evanpage?sslmode=disable} + SERVER_PORT: ${SERVER_PORT:-8080} + SERVER_API_URL: ${SERVER_API_URL:-http://backend:8080} + AUTH_SECRET: ${AUTH_SECRET:-} + AUTH_KEYCLOAK_ISSUER: ${AUTH_KEYCLOAK_ISSUER:-} + AUTH_KEYCLOAK_ID: ${AUTH_KEYCLOAK_ID:-} + AUTH_KEYCLOAK_SECRET: ${AUTH_KEYCLOAK_SECRET:-} + ports: + - "8080:8080" + volumes: + - ./backend:/app + - /app/tmp + depends_on: + db: + condition: service_healthy + networks: + - evanpage-net + + frontend: + build: + context: ./frontend + container_name: evanpage-frontend + environment: + SERVER_API_URL: ${SERVER_API_URL:-http://backend:8080} + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080} + AUTH_SECRET: ${AUTH_SECRET:-} + AUTH_KEYCLOAK_ISSUER: ${AUTH_KEYCLOAK_ISSUER:-} + AUTH_KEYCLOAK_ID: ${AUTH_KEYCLOAK_ID:-} + AUTH_KEYCLOAK_SECRET: ${AUTH_KEYCLOAK_SECRET:-} + ports: + - "3000:3000" + volumes: + - ./frontend:/app + - /app/node_modules + depends_on: + - backend + networks: + - evanpage-net + +volumes: + pgdata: + +networks: + evanpage-net: + driver: bridge diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..27d055a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +EXPOSE 3000 +ENV NODE_ENV=development +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +CMD ["npm", "run", "dev"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/app/(main)/admin/page.tsx b/frontend/app/(main)/admin/page.tsx new file mode 100644 index 0000000..5d23713 --- /dev/null +++ b/frontend/app/(main)/admin/page.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +interface User { + id: number; + username: string; + email: string; + role: string; + createdAt: string; +} + +export default function AdminPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [open, setOpen] = useState(false); + const [form, setForm] = useState({ + username: "", + email: "", + password: "", + role: "user", + }); + + async function fetchUsers() { + const res = await fetch("/api/proxy/admin/users"); + if (res.ok) { + const data = await res.json(); + setUsers(data.users || []); + } + setLoading(false); + } + + useEffect(() => { + fetchUsers(); + }, []); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + const res = await fetch("/api/proxy/admin/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + + if (res.ok) { + setOpen(false); + setForm({ username: "", email: "", password: "", role: "user" }); + fetchUsers(); + } + } + + async function handleDelete(id: number) { + if (!confirm("确定删除该用户?")) return; + const res = await fetch(`/api/proxy/admin/users/${id}`, { + method: "DELETE", + }); + if (res.ok) { + fetchUsers(); + } + } + + return ( +
+
+

用户管理

+ + 创建用户} /> + + + 新建用户 + +
+
+ + + setForm({ ...form, username: e.target.value }) + } + required + /> +
+
+ + + setForm({ ...form, email: e.target.value }) + } + required + /> +
+
+ + + setForm({ ...form, password: e.target.value }) + } + required + /> +
+
+ + +
+ +
+
+
+
+ + + + 用户列表 + + + {loading ? ( +

加载中...

+ ) : ( + + + + ID + 用户名 + 邮箱 + 角色 + 操作 + + + + {users.map((user) => ( + + {user.id} + {user.username} + {user.email} + {user.role} + + + + + ))} + +
+ )} +
+
+
+ ); +} diff --git a/frontend/app/(main)/dashboard/page.tsx b/frontend/app/(main)/dashboard/page.tsx new file mode 100644 index 0000000..1f564c1 --- /dev/null +++ b/frontend/app/(main)/dashboard/page.tsx @@ -0,0 +1,32 @@ +import { auth } from "@/auth"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default async function DashboardPage() { + const session = await auth(); + const user = session?.user as any; + + return ( +
+

欢迎,{user?.name || user?.email}

+ + + 用户信息 + + +

+ 用户名: + {user?.name} +

+

+ 邮箱: + {user?.email} +

+

+ 角色: + {user?.role} +

+
+
+
+ ); +} diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx new file mode 100644 index 0000000..1e3ac30 --- /dev/null +++ b/frontend/app/(main)/layout.tsx @@ -0,0 +1,46 @@ +import Link from "next/link"; +import { auth } from "@/auth"; +import { Button } from "@/components/ui/button"; +import { signOut } from "@/auth"; + +export default async function MainLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await auth(); + const user = session?.user as any; + + return ( +
+
+
+ + EvanPage + + +
+
+
{children}
+
+ ); +} diff --git a/frontend/app/api/auth/[...nextauth]/route.ts b/frontend/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..d7ce0d6 --- /dev/null +++ b/frontend/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { GET, POST } from "@/auth"; + +export { GET, POST }; diff --git a/frontend/app/api/proxy/[[...path]]/route.ts b/frontend/app/api/proxy/[[...path]]/route.ts new file mode 100644 index 0000000..75df245 --- /dev/null +++ b/frontend/app/api/proxy/[[...path]]/route.ts @@ -0,0 +1,48 @@ +import { auth } from "@/auth"; +import { NextRequest, NextResponse } from "next/server"; + +const BACKEND_URL = process.env.SERVER_API_URL || "http://backend:8080"; + +async function handler( + req: NextRequest, + { params }: { params: Promise<{ path?: string[] }> } +) { + const session = await auth(); + const { path } = await params; + const pathStr = path?.join("/") || ""; + const url = `${BACKEND_URL}/api/${pathStr}${req.nextUrl.search}`; + + const headers = new Headers(req.headers); + headers.delete("host"); + + if (session?.user) { + headers.set("X-User-Id", (session.user as any).id || ""); + headers.set("X-User-Role", (session.user as any).role || ""); + } + + const body = + req.method === "GET" || req.method === "HEAD" + ? undefined + : await req.arrayBuffer(); + + const res = await fetch(url, { + method: req.method, + headers, + body, + }); + + const data = await res.arrayBuffer(); + + return new NextResponse(data, { + status: res.status, + statusText: res.statusText, + headers: res.headers, + }); +} + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const DELETE = handler; +export const PATCH = handler; +export const OPTIONS = handler; diff --git a/frontend/app/bind-account/page.tsx b/frontend/app/bind-account/page.tsx new file mode 100644 index 0000000..11faac2 --- /dev/null +++ b/frontend/app/bind-account/page.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { Suspense } from "react"; +import { useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { signIn } from "next-auth/react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +function BindForm() { + const searchParams = useSearchParams(); + const router = useRouter(); + const keycloakId = searchParams.get("keycloakId") || ""; + const keycloakEmail = searchParams.get("email") || ""; + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + const res = await fetch("/api/proxy/auth/bind-keycloak", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username, + password, + keycloakId, + keycloakEmail, + }), + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error || "绑定失败"); + return; + } + + const data = await res.json(); + if (data.bound) { + await signIn("keycloak", { callbackUrl: "/dashboard" }); + } + } + + return ( + + + 绑定本地账号 + + +

+ 您的 Keycloak 账号({keycloakEmail || keycloakId})尚未绑定本地账户。 + 请输入已有的本地账号密码完成绑定。 +

+
+
+ + setUsername(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} + +
+
+
+ ); +} + +export default function BindAccountPage() { + return ( +
+ 加载中...
}> + + + + ); +} diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/app/favicon.ico differ diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..c56032b --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,130 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-geist-mono); + --font-heading: var(--font-sans); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); +} + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} \ No newline at end of file diff --git a/frontend/app/init/page.tsx b/frontend/app/init/page.tsx new file mode 100644 index 0000000..12b32da --- /dev/null +++ b/frontend/app/init/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function InitPage() { + const router = useRouter(); + const [loading, setLoading] = useState(true); + const [initialized, setInitialized] = useState(true); + const [form, setForm] = useState({ + username: "", + email: "", + password: "", + confirmPassword: "", + }); + const [error, setError] = useState(""); + + useEffect(() => { + fetch("/api/proxy/admin/users", { + headers: { "X-User-Role": "admin" }, + }) + .then((res) => { + if (res.ok) { + setInitialized(true); + } else { + setInitialized(false); + } + }) + .catch(() => setInitialized(false)) + .finally(() => setLoading(false)); + }, []); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (form.password !== form.confirmPassword) { + setError("两次输入的密码不一致"); + return; + } + + const res = await fetch("/api/proxy/auth/init", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: form.username, + email: form.email, + password: form.password, + }), + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error || "初始化失败"); + return; + } + + router.push("/login"); + } + + if (loading) { + return ( +
+ 加载中... +
+ ); + } + + if (initialized) { + return ( +
+ + + 系统已初始化 + + +

+ 系统中已有用户,无法再次初始化。 +

+
+
+
+ ); + } + + return ( +
+ + + 系统初始化 + + +

+ 这是系统首次启动,请创建第一个管理员账号。 +

+
+
+ + setForm({ ...form, username: e.target.value })} + required + /> +
+
+ + setForm({ ...form, email: e.target.value })} + required + /> +
+
+ + setForm({ ...form, password: e.target.value })} + required + /> +
+
+ + + setForm({ ...form, confirmPassword: e.target.value }) + } + required + /> +
+ {error &&

{error}

} + +
+
+
+
+ ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..976eb90 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/frontend/app/login/login-form.tsx b/frontend/app/login/login-form.tsx new file mode 100644 index 0000000..42bb736 --- /dev/null +++ b/frontend/app/login/login-form.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useState } from "react"; +import { signIn } from "next-auth/react"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export function LoginForm({ hasKeycloak }: { hasKeycloak: boolean }) { + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get("callbackUrl") || "/dashboard"; + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + const res = await signIn("credentials", { + username, + password, + redirect: false, + callbackUrl, + }); + + if (res?.error) { + setError("登录失败,请检查用户名和密码"); + } else { + const redirectUrl = callbackUrl.startsWith("http") + ? callbackUrl + : window.location.origin + callbackUrl; + window.location.href = redirectUrl; + } + } + + return ( + + + 登录 + + +
+
+ + setUsername(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&

{error}

} + +
+ + {hasKeycloak && ( + <> +
+
+ +
+
+ 或者 +
+
+ + + + )} + +

+ 还没有账号?{" "} + + 立即注册 + +

+
+
+ ); +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx new file mode 100644 index 0000000..7d32bcd --- /dev/null +++ b/frontend/app/login/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from "react"; +import { LoginForm } from "./login-form"; + +const hasKeycloak = !!process.env.AUTH_KEYCLOAK_ISSUER; + +export default function LoginPage() { + return ( +
+ 加载中...
}> + + + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..887811e --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,64 @@ +import { BlurFade } from "@/components/magicui/blur-fade"; + +const SERVER_API_URL = process.env.SERVER_API_URL || "http://backend:8080"; + +export default async function HomePage() { + let healthText = "无法连接到后端服务"; + + try { + const res = await fetch(`${SERVER_API_URL}/api/health`, { + cache: "no-store", + }); + if (res.ok) { + healthText = await res.text(); + } else { + healthText = `后端异常: ${res.status}`; + } + } catch (err) { + healthText = "后端连接失败"; + } + + return ( +
+ +

+ EvanPage +

+
+ + +

+ 全栈基础框架 +

+
+ + +
+

+ 后端状态 +

+

+ {healthText} +

+
+
+ + + + +
+ ); +} diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx new file mode 100644 index 0000000..0d38110 --- /dev/null +++ b/frontend/app/register/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function RegisterPage() { + const router = useRouter(); + const [form, setForm] = useState({ + username: "", + email: "", + password: "", + confirmPassword: "", + }); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + + if (form.password !== form.confirmPassword) { + setError("两次输入的密码不一致"); + return; + } + + const res = await fetch("/api/proxy/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: form.username, + email: form.email, + password: form.password, + }), + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error || "注册失败"); + return; + } + + router.push("/login"); + } + + return ( +
+ + + 注册 + + +
+
+ + setForm({ ...form, username: e.target.value })} + required + /> +
+
+ + setForm({ ...form, email: e.target.value })} + required + /> +
+
+ + setForm({ ...form, password: e.target.value })} + required + /> +
+
+ + + setForm({ ...form, confirmPassword: e.target.value }) + } + required + /> +
+ {error &&

{error}

} + +
+ +

+ 已有账号?{" "} + + 去登录 + +

+
+
+
+ ); +} diff --git a/frontend/app/unauthorized/page.tsx b/frontend/app/unauthorized/page.tsx new file mode 100644 index 0000000..7671af8 --- /dev/null +++ b/frontend/app/unauthorized/page.tsx @@ -0,0 +1,11 @@ +export default function UnauthorizedPage() { + return ( +
+

403

+

您没有权限访问该页面

+ + 返回仪表盘 + +
+ ); +} diff --git a/frontend/auth.ts b/frontend/auth.ts new file mode 100644 index 0000000..29b44d1 --- /dev/null +++ b/frontend/auth.ts @@ -0,0 +1,104 @@ +import NextAuth from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import Keycloak from "next-auth/providers/keycloak"; + +const SERVER_API_URL = process.env.SERVER_API_URL || "http://backend:8080"; + +const providers: any[] = [ + Credentials({ + name: "local", + credentials: { + username: { label: "Username", type: "text" }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + if (!credentials?.username || !credentials?.password) return null; + + const res = await fetch(`${SERVER_API_URL}/api/auth/local-login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: credentials.username, + password: credentials.password, + }), + }); + + if (!res.ok) return null; + + const user = await res.json(); + return { + id: String(user.id), + name: user.username, + email: user.email, + role: user.role, + }; + }, + }), +]; + +if (process.env.AUTH_KEYCLOAK_ISSUER) { + providers.push( + Keycloak({ + clientId: process.env.AUTH_KEYCLOAK_ID!, + clientSecret: process.env.AUTH_KEYCLOAK_SECRET!, + issuer: process.env.AUTH_KEYCLOAK_ISSUER!, + }) + ); +} + +export const { + handlers: { GET, POST }, + auth, + signIn, + signOut, +} = NextAuth({ + providers, + callbacks: { + async signIn({ user, account, profile }) { + if (account?.provider === "keycloak" && profile) { + const keycloakId = profile.sub as string; + const email = (profile.email as string) || ""; + + const res = await fetch(`${SERVER_API_URL}/api/auth/lookup-binding`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keycloakId, email }), + }); + + if (!res.ok) return false; + + const data = await res.json(); + + if (data.bound) { + (user as any).id = String(data.user.id); + (user as any).role = data.user.role; + return true; + } + + return `/bind-account?keycloakId=${encodeURIComponent(keycloakId)}&email=${encodeURIComponent(email)}`; + } + return true; + }, + async jwt({ token, user }) { + if (user) { + token.id = user.id; + token.role = (user as any).role; + } + return token; + }, + async session({ session, token }) { + if (session.user) { + (session.user as any).id = token.id; + (session.user as any).role = token.role; + } + return session; + }, + }, + pages: { + signIn: "/login", + error: "/login", + }, + session: { + strategy: "jwt", + }, +}); diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..f382eb7 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-nova", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/frontend/components/magicui/blur-fade.tsx b/frontend/components/magicui/blur-fade.tsx new file mode 100644 index 0000000..845dc91 --- /dev/null +++ b/frontend/components/magicui/blur-fade.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { motion, useInView, Variants } from "framer-motion"; +import { useRef } from "react"; + +interface BlurFadeProps { + children: React.ReactNode; + className?: string; + variant?: { + hidden: { y: number }; + visible: { y: number }; + }; + duration?: number; + delay?: number; + yOffset?: number; + inView?: boolean; + inViewMargin?: `${number}px` | `${number}%` | `${number}px ${number}px` | `${number}px ${number}px ${number}px ${number}px`; + blur?: string; +} + +export function BlurFade({ + children, + className, + variant, + duration = 0.4, + delay = 0, + yOffset = 6, + inView = false, + inViewMargin = "-50px", + blur = "6px", +}: BlurFadeProps) { + const ref = useRef(null); + const isInView = useInView(ref, { once: true, margin: inViewMargin }); + + const defaultVariants: Variants = { + hidden: { y: yOffset, opacity: 0, filter: `blur(${blur})` }, + visible: { y: -yOffset, opacity: 1, filter: `blur(0px)` }, + }; + + const combinedVariants = variant || defaultVariants; + + return ( + + {children} + + ); +} diff --git a/frontend/components/ui/button.tsx b/frontend/components/ui/button.tsx new file mode 100644 index 0000000..09df753 --- /dev/null +++ b/frontend/components/ui/button.tsx @@ -0,0 +1,58 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx new file mode 100644 index 0000000..40cac5f --- /dev/null +++ b/frontend/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx new file mode 100644 index 0000000..014f5aa --- /dev/null +++ b/frontend/components/ui/dialog.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + }> + Close + + )} +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/frontend/components/ui/input.tsx @@ -0,0 +1,20 @@ +import * as React from "react" +import { Input as InputPrimitive } from "@base-ui/react/input" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/frontend/components/ui/label.tsx b/frontend/components/ui/label.tsx new file mode 100644 index 0000000..74da65c --- /dev/null +++ b/frontend/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +