├── .air.toml ├── .dockerignore ├── .gitignore ├── .gitmodules ├── Dockerfile.base ├── Dockerfile.www ├── Taskfile.yml ├── bun.lockb ├── docker-compose.yml ├── go.mod ├── go.work ├── go.work.sum ├── license ├── package.json ├── pages ├── async │ └── async.go ├── components.go ├── font │ └── font.go ├── go.mod ├── go.sum ├── i18n │ └── i18n.go ├── internal │ └── internal.go ├── middleware │ └── middleware.go ├── pages.go ├── pcpg │ ├── compile.go │ ├── generator │ │ └── generator.go │ ├── install.go │ ├── main.go │ ├── parser │ │ └── parser.go │ └── static │ │ └── stream.js └── perf.go ├── readme.md ├── staticcheck.conf ├── ui ├── components │ ├── alert.go │ ├── avatar.go │ ├── badge.go │ ├── bench_test.go │ ├── breadcrumb.go │ ├── button.go │ ├── calendar.go │ ├── card.go │ ├── carousel.go │ ├── checkbox.go │ ├── code.go │ ├── collapsible.go │ ├── components.go │ ├── dialog.go │ ├── dist │ │ ├── main.css │ │ ├── main.js │ │ └── main.js.map │ ├── dropdown.go │ ├── input.go │ ├── label.go │ ├── radio.go │ ├── select.go │ ├── seperator.go │ ├── sheet.go │ ├── slider.go │ ├── src │ │ ├── main.css │ │ └── main.ts │ ├── switch.go │ ├── table.go │ ├── tabs.go │ ├── textarea.go │ ├── toast.go │ └── tooltip.go ├── go.mod ├── go.sum ├── html │ ├── html.go │ └── renderer.go └── icons │ ├── generator │ └── main.go │ ├── icon.go │ ├── icon_test.go │ └── icons.go └── www ├── app ├── assets │ ├── banner.webp │ ├── favicon.webp │ ├── logo.webp │ ├── main.css │ └── main.ts ├── auth.go ├── components │ ├── button.go │ ├── plate.go │ └── title.go ├── docs.go ├── docs │ ├── getting-started │ │ ├── contributing.md │ │ ├── installation.md │ │ ├── introduction.md │ │ └── quick-start.md │ ├── pages │ │ ├── actions.md │ │ ├── font.md │ │ ├── i18n.md │ │ ├── layouts.md │ │ ├── middleware.md │ │ ├── overview.md │ │ ├── pages.md │ │ ├── prefetching.md │ │ └── streaming.md │ └── ui │ │ ├── components │ │ ├── alert.md │ │ ├── avatar.md │ │ ├── badge.md │ │ ├── button.md │ │ ├── calendar.md │ │ ├── card.md │ │ ├── carousel.md │ │ ├── checkbox.md │ │ ├── code.md │ │ ├── collapsible.md │ │ ├── dialog.md │ │ ├── dropdown.md │ │ ├── input.md │ │ ├── label.md │ │ ├── overview.md │ │ ├── radio.md │ │ ├── select.md │ │ ├── seperator.md │ │ ├── sheet.md │ │ ├── slider.md │ │ ├── switch.md │ │ ├── table.md │ │ ├── tabs.md │ │ ├── textarea.md │ │ ├── toast.md │ │ └── tooltip.md │ │ ├── icons │ │ └── custom-icons.md │ │ └── templating │ │ ├── attributes.md │ │ ├── elements.md │ │ ├── extending.md │ │ └── utilities.md ├── home.go ├── icons.go ├── layout.go ├── messages │ └── en.json ├── misc.go ├── renderer.go ├── robots.txt ├── share.go └── sitemap.xml ├── go.mod ├── go.sum └── main.go /.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/www" 8 | cmd = "go build -o ./tmp/www ./www/" 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules", "ui/lucide", "www/app/static"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go", ".gen.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html", "ts"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = ["task build-ui-assets", "go run ./pages/pcpg compile www"] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = false 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | time = false 40 | 41 | [misc] 42 | clean_on_exit = false 43 | 44 | [proxy] 45 | app_port = 0 46 | enabled = false 47 | proxy_port = 0 48 | 49 | [screen] 50 | clear_on_rebuild = false 51 | keep_scroll = true 52 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, built with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | tmp 17 | dist 18 | # Dependency directories 19 | # vendor/ 20 | 21 | # env file 22 | .env 23 | 24 | node_modules 25 | www/app/static 26 | www/app/*.gen.go -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lucide"] 2 | path = ui/lucide 3 | url = https://github.com/lucide-icons/lucide 4 | [submodule "ui/lucide"] 5 | path = ui/lucide 6 | url = https://github.com/lucide-icons/lucide 7 | -------------------------------------------------------------------------------- /Dockerfile.base: -------------------------------------------------------------------------------- 1 | FROM golang:1.24 AS builder 2 | WORKDIR /pcpg 3 | COPY . . 4 | RUN go build -o /usr/local/bin/pcpg ./pages/pcpg 5 | 6 | FROM golang:1.24 7 | COPY --from=builder /usr/local/bin/pcpg /usr/local/bin/pcpg 8 | RUN chmod +x /usr/local/bin/pcpg 9 | ENV PATH="/usr/local/bin:${PATH}" -------------------------------------------------------------------------------- /Dockerfile.www: -------------------------------------------------------------------------------- 1 | FROM canpacis/pacis:latest 2 | 3 | # Set working directory 4 | WORKDIR /app 5 | 6 | # Install dependencies 7 | RUN pcpg i 8 | 9 | # Copy the source code 10 | COPY . . 11 | 12 | # Build 13 | RUN pcpg c www 14 | RUN go build -o pacis-www ./www 15 | 16 | EXPOSE 8081 17 | 18 | CMD ["./pacis-www"] -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | # https://taskfile.dev 2 | 3 | version: "3" 4 | 5 | tasks: 6 | build-ui-assets: 7 | cmds: 8 | - npx @tailwindcss/cli -i ./ui/components/src/main.css -o ./ui/components/dist/main.css 9 | - esbuild ui/components/src/main.ts --bundle --outfile=./ui/components/dist/main.js --minify --sourcemap 10 | build-ui-icons: 11 | cmds: 12 | - go generate ui/icons/icon.go 13 | build-ui: 14 | cmds: 15 | - task build-ui-icons 16 | - task build-ui-assets 17 | build-base-docker: 18 | cmds: 19 | - docker buildx build --platform linux/amd64,linux/arm64 -t canpacis/pacis:latest -f Dockerfile.base --push . 20 | build-www-docker: 21 | cmds: 22 | - docker build -t canpacis/pacis-www -f Dockerfile.www . 23 | - docker push canpacis/pacis-www 24 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canpacis/pacis/89628a27959d1c40c80a278f3ca808af72edf5f0/bun.lockb -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | webapp: 3 | build: 4 | context: . 5 | dockerfile: Dockerfile.www 6 | ports: 7 | - 8081 8 | environment: 9 | - ENVIRONMENT=development 10 | - GOOGLE_OAUTH_CLIENT_ID=${WEBAPP_GOOGLE_OAUTH_CLIENT_ID} 11 | - GOOGLE_OAUTH_CLIENT_SECRET=${WEBAPP_GOOGLE_OAUTH_CLIENT_SECRET} 12 | - OAUTH_CALLBACK_URL=${WEBAPP_URL}/auth/callback 13 | - REDIS_URL=redis://webapp-redis:6379 14 | - REDIS_USERNAME=default 15 | - REDIS_PASSWORD=password 16 | - AUTHORIZER_ID=${WEBAPP_AUTHORIZER_ID} 17 | - AUTHORIZER_SECRET={WEBAPP_AUTHORIZER_SECRET} 18 | - APP_URL=${WEBAPP_URL} 19 | - AUTHORIZER_URL=${AUTHORIZER_URL} 20 | - UMAMI_URL=${UMAMI_URL} 21 | depends_on: 22 | - webapp-redis 23 | 24 | webapp-redis: 25 | image: redis:7-alpine 26 | restart: unless-stopped 27 | ports: 28 | - "6061:6379" 29 | volumes: 30 | - webapp_redis_data:/data 31 | 32 | # authorizer: 33 | # image: lakhansamani/authorizer:1.4.4 34 | # restart: unless-stopped 35 | # ports: 36 | # - 8080 37 | # environment: 38 | # - DATABASE_TYPE=postgres 39 | # - DATABASE_URL=postgres://postgres:${AUTHORIZER_DB_PASSWORD}@authorizer-db:5432/authorizer?sslmode=disable 40 | # - REDIS_URL=redis://authorizer-redis:6379 41 | # - ADMIN_SECRET=${AUTHORIZER_ADMIN_SECRET} 42 | # - JWT_SECRET=${AUTHORIZER_JWT_SECRET} 43 | # - COOKIE_NAME=authorizer 44 | # - ACCESS_TOKEN_EXPIRY_TIME=86400 45 | # - REFRESH_TOKEN_EXPIRY_TIME=86400 46 | # - DISABLE_PLAYGROUND=true 47 | # depends_on: 48 | # - authorizer-db 49 | # - authorizer-redis 50 | 51 | # authorizer-db: 52 | # image: postgres:15-alpine 53 | # restart: unless-stopped 54 | # ports: 55 | # - "5050:5432" 56 | # environment: 57 | # - POSTGRES_USER=postgres 58 | # - POSTGRES_PASSWORD=${AUTHORIZER_DB_PASSWORD} 59 | # - POSTGRES_DB=authorizer 60 | # volumes: 61 | # - authorizer_db_data:/var/lib/postgresql/data 62 | 63 | # authorizer-redis: 64 | # image: redis:7-alpine 65 | # restart: unless-stopped 66 | # ports: 67 | # - "6060:6379" 68 | # volumes: 69 | # - authorizer_redis_data:/data 70 | 71 | # umami: 72 | # image: ghcr.io/umami-software/umami:postgresql-v2.16.1 73 | # restart: always 74 | # healthcheck: 75 | # test: ["CMD-SHELL", "curl http://localhost:3000/api/heartbeat"] 76 | # interval: 5s 77 | # timeout: 5s 78 | # retries: 5 79 | # depends_on: 80 | # umami-db: 81 | # condition: service_healthy 82 | # environment: 83 | # - DATABASE_URL=postgresql://umami:${UMAMI_DB_PASSWORD}@umami-db:5432/umami 84 | # - DATABASE_TYPE=postgresql 85 | # - APP_SECRET=${UMAMI_APP_SECRET} 86 | # umami-db: 87 | # image: postgres:15-alpine 88 | # restart: always 89 | # ports: 90 | # - "5052:5432" 91 | # healthcheck: 92 | # test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] 93 | # interval: 5s 94 | # timeout: 5s 95 | # retries: 5 96 | 97 | # volumes: 98 | # - umami_db_data:/var/lib/postgresql/data 99 | # environment: 100 | # - POSTGRES_USER=umami 101 | # - POSTGRES_PASSWORD=${UMAMI_DB_PASSWORD} 102 | # - POSTGRES_DB=umami 103 | 104 | volumes: 105 | # authorizer_db_data: {} 106 | # umami_db_data: {} 107 | # authorizer_redis_data: {} 108 | webapp_redis_data: {} 109 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/canpacis/pacis 2 | 3 | go 1.24.1 4 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.24.1 2 | 3 | use ./ui 4 | use ./pages 5 | use ./www -------------------------------------------------------------------------------- /go.work.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM= 2 | cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4= 3 | github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= 4 | github.com/agiledragon/gomonkey/v2 v2.11.0 h1:5oxSgA+tC1xuGsrIorR+sYiziYltmJyEZ9qA25b6l5U= 5 | github.com/agiledragon/gomonkey/v2 v2.11.0/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= 6 | github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= 7 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 8 | github.com/canpacis/pacis v0.3.0/go.mod h1:CqgyL88muH2VOEx5I9UYP5p7ZwuZbPiwjRh9ACF1tKs= 9 | github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= 10 | github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= 11 | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= 12 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 13 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 14 | github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= 15 | github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= 16 | github.com/dghubble/go-twitter v0.0.0-20221104224141-912508c3888b/go.mod h1:B0/qdW5XUupJvcsx40hnVbfjzz9He5YpYXx6eVVdiSY= 17 | github.com/dghubble/oauth1 v0.7.2/go.mod h1:9erQdIhqhOHG/7K9s/tgh9Ks/AfoyrO5mW/43Lu2+kE= 18 | github.com/dghubble/sling v1.4.1/go.mod h1:QoMB1KL3GAo+7HsD8Itd6S+6tW91who8BGZzuLvpOyc= 19 | github.com/envoyproxy/go-control-plane v0.11.1/go.mod h1:uhMcXKCQMEJHiAb0w+YGefQLaTEw+YhGluxZkrTmD0g= 20 | github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= 21 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 22 | github.com/gin-contrib/sessions v1.0.1/go.mod h1:ouxSFM24/OgIud5MJYQJLpy6AwxQ5EYO9yLhbtObGkM= 23 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 24 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 25 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 26 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 27 | github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 28 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 29 | github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= 30 | github.com/google/go-github/v52 v52.0.0/go.mod h1:WJV6VEEUPuMo5pXqqa2ZCZEdbQqua4zAk2MZTIo+m+4= 31 | github.com/google/go-pkcs11 v0.2.1-0.20230907215043-c6f79328ddf9/go.mod h1:6eQoGcuNJpa7jnd5pMGdkSaQpNDYvPlXWMcjXXThLlY= 32 | github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 33 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 34 | github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= 35 | github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= 36 | github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= 37 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 38 | github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 39 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 40 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 41 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 42 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 43 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 44 | github.com/quasoft/memstore v0.0.0-20191010062613-2bce066d2b0b/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg= 45 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 46 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 47 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 48 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 49 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 50 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.48.0/go.mod h1:tIKj3DbO8N9Y2xo52og3irLsPI4GW02DSMtrVgNMgxg= 51 | go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= 52 | golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 53 | golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= 54 | golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= 55 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 56 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 57 | golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 58 | golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 59 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 60 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 61 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 62 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 63 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 64 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 65 | golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= 66 | golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 67 | golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= 68 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 69 | golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= 70 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 71 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 72 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 73 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 74 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 75 | golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= 76 | golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0= 77 | google.golang.org/genproto v0.0.0-20240205150955-31a09d347014/go.mod h1:xEgQu1e4stdSSsxPDK8Azkrk/ECl5HvdPf6nbZrTS5M= 78 | google.golang.org/genproto/googleapis/api v0.0.0-20240205150955-31a09d347014/go.mod h1:rbHMSEDyoYX62nRVLOCc4Qt1HbsdytAYoVwgjiOhF3I= 79 | google.golang.org/genproto/googleapis/bytestream v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:om8Bj876Z0v9ei+RD1LnEWig7vpHQ371PUqsgjmLQEA= 80 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 81 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | Copyright 2025 Muhammed Ali CAN 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "@alpinejs/anchor": "^3.14.9", 4 | "@alpinejs/collapse": "^3.14.9", 5 | "@alpinejs/focus": "^3.14.9", 6 | "@alpinejs/intersect": "^3.14.9", 7 | "@alpinejs/persist": "^3.14.9", 8 | "@tailwindcss/cli": "^4.0.17", 9 | "@types/alpinejs": "^3.13.11", 10 | "@types/alpinejs__anchor": "^3.13.1", 11 | "@types/alpinejs__collapse": "^3.13.4", 12 | "@types/alpinejs__focus": "^3.13.4", 13 | "@types/alpinejs__intersect": "^3.13.4", 14 | "@types/alpinejs__persist": "^3.13.4", 15 | "alpinejs": "^3.14.9", 16 | "esbuild": "^0.25.2", 17 | "tailwindcss": "^4.0.17", 18 | "tailwindcss-animate": "^1.0.7" 19 | }, 20 | "devDependencies": { 21 | "minify": "^14.0.0", 22 | "typescript": "^5.8.2" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pages/async/async.go: -------------------------------------------------------------------------------- 1 | package async 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "io" 8 | 9 | c "github.com/canpacis/pacis/ui/components" 10 | h "github.com/canpacis/pacis/ui/html" 11 | ) 12 | 13 | func randid() string { 14 | buf := make([]byte, 8) 15 | rand.Read(buf) 16 | return "pacis-" + hex.EncodeToString(buf) 17 | } 18 | 19 | type StreamElement struct { 20 | fn func() h.Element 21 | fallback h.Node 22 | } 23 | 24 | func (n *StreamElement) Render(ctx context.Context, w io.Writer) error { 25 | // TODO 26 | // pctx, ok := ctx.(*pages.Context) 27 | // // If context is not a page context, render sync 28 | // if !ok { 29 | // return n.fn().Render(ctx, w) 30 | // } 31 | 32 | id := randid() 33 | // dequeue := pctx.QueueElement() 34 | 35 | go func(fn func() h.Element, id string) { 36 | // for !pctx.Ready() { 37 | // time.Sleep(time.Microsecond * 100) 38 | // } 39 | element := fn() 40 | element.AddAttribute(h.SlotAttr(id)) 41 | element.AddAttribute(c.X("show", "false")) 42 | // dequeue(element) 43 | }(n.fn, id) 44 | 45 | placholder := h.Slot(h.Name(id)) 46 | 47 | if n.fallback != nil { 48 | placholder.AddNode(n.fallback) 49 | } 50 | return placholder.Render(ctx, w) 51 | } 52 | 53 | func (*StreamElement) NodeType() h.NodeType { 54 | return h.NodeFragment 55 | } 56 | 57 | func Element(fn func() h.Element, fallback ...h.Node) *StreamElement { 58 | el := &StreamElement{fn: fn} 59 | if len(fallback) != 0 { 60 | el.fallback = fallback[0] 61 | } 62 | return el 63 | } 64 | -------------------------------------------------------------------------------- /pages/components.go: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | 12 | c "github.com/canpacis/pacis/ui/components" 13 | "github.com/canpacis/pacis/ui/html" 14 | ) 15 | 16 | func readattr(attr html.Attribute) string { 17 | var buf bytes.Buffer 18 | attr.Render(context.Background(), &buf) 19 | return buf.String() 20 | } 21 | 22 | type Link struct { 23 | html.Element 24 | } 25 | 26 | func (l *Link) Render(ctx context.Context, w io.Writer) error { 27 | hrefattr, ok := l.Element.GetAttribute("href") 28 | if !ok { 29 | return l.Element.Render(ctx, w) 30 | } 31 | href := readattr(hrefattr) 32 | 33 | // In-page link, just render regular anchor 34 | if strings.HasPrefix(href, "#") { 35 | return l.Element.Render(ctx, w) 36 | } 37 | 38 | parsed, err := url.Parse(href) 39 | // Valid external url or broken url, just render regular anchor 40 | if err != nil || len(parsed.Host) != 0 { 41 | l.Element.AddAttribute(html.Target("blank")) 42 | l.Element.AddAttribute(html.Rel("noreferer")) 43 | return l.Element.Render(ctx, w) 44 | } 45 | 46 | l.Element.AddAttribute(c.On("mouseenter", fmt.Sprintf("$prefetch.queue('%s')", href))) 47 | l.Element.AddAttribute(c.On("click", fmt.Sprintf("$prefetch.load('%s', $event)", href))) 48 | 49 | _, eager := l.Element.GetAttribute("eager") 50 | if eager { 51 | l.Element.RemoveAttribute("eager") 52 | l.Element.AddAttribute(c.X("intersect", fmt.Sprintf("$prefetch.queue('%s')", href))) 53 | } 54 | return l.Element.Render(ctx, w) 55 | } 56 | 57 | func (*Link) NodeType() html.NodeType { 58 | return html.NodeElement 59 | } 60 | 61 | var Eager = html.Attr("eager", true) 62 | 63 | func A(props ...html.I) html.Element { 64 | return &Link{Element: html.A(props...)} 65 | } 66 | 67 | func Outlet(ctx *Context) html.I { 68 | return ctx.outlet 69 | } 70 | 71 | func Head(ctx *Context) html.I { 72 | meta := ctx.head.meta 73 | if meta == nil { 74 | meta = &Metadata{} 75 | } 76 | pagemeta, ok := pagemetadata[ctx.pattern] 77 | if ok { 78 | meta = pagemeta.Merge(meta) 79 | } 80 | return html.Frag(ctx.head.content, meta) 81 | } 82 | 83 | func Body(ctx *Context) html.I { 84 | return ctx.body 85 | } 86 | 87 | func Redirect(ctx *Context, to string) html.I { 88 | ctx.w.Header().Set("Content-Type", "text/html") 89 | http.Redirect(ctx.w, ctx.r, to, http.StatusTemporaryRedirect) 90 | return html.Frag() 91 | } 92 | 93 | func NotFound(ctx *Context) html.I { 94 | return NotFoundPage.Page(ctx) 95 | } 96 | 97 | func Error(ctx *Context, errpage ErrorPage) html.I { 98 | ctx.w.WriteHeader(errpage.Status()) 99 | return errpage.Page(ctx) 100 | } 101 | 102 | func SetCookie(ctx *Context, cookies ...*http.Cookie) { 103 | for _, cookie := range cookies { 104 | http.SetCookie(ctx.w, cookie) 105 | } 106 | } 107 | 108 | type Header interface { 109 | Key() string 110 | Value() string 111 | } 112 | 113 | type HeaderEntry struct { 114 | key string 115 | value string 116 | } 117 | 118 | func (he *HeaderEntry) Key() string { 119 | return he.key 120 | } 121 | 122 | func (he *HeaderEntry) Value() string { 123 | return he.value 124 | } 125 | 126 | func NewHeader(key, value string) *HeaderEntry { 127 | return &HeaderEntry{key: key, value: value} 128 | } 129 | 130 | func SetHeader(ctx *Context, headers ...Header) { 131 | for _, header := range headers { 132 | ctx.w.Header().Set(header.Key(), header.Value()) 133 | } 134 | } 135 | 136 | type MetadataOG struct { 137 | Type string 138 | URL string 139 | Title string 140 | Description string 141 | // Should the absolute path to an asset 142 | Image string 143 | } 144 | 145 | type MetadataTwitter struct { 146 | Card string 147 | URL string 148 | Title string 149 | Description string 150 | // Should the absolute path to an asset 151 | Image string 152 | } 153 | 154 | type Metadata struct { 155 | Base string 156 | Title string 157 | Description string 158 | AppName string 159 | Authors []string 160 | Generator string 161 | Keywords []string 162 | Referrer string 163 | Creator string 164 | Publisher string 165 | Robots string 166 | Manifest string 167 | Icons string 168 | Language string 169 | Alternates struct { 170 | Canonical string 171 | Languages []string 172 | Media string 173 | Types []string 174 | } 175 | OpenGraph *MetadataOG 176 | Twitter *MetadataTwitter 177 | Assets []string 178 | } 179 | 180 | func (m *Metadata) Merge(other *Metadata) *Metadata { 181 | var selct = func(a, b string) string { 182 | if len(a) == 0 { 183 | return b 184 | } else { 185 | return a 186 | } 187 | } 188 | var selctslc = func(a, b []string) []string { 189 | if len(a) == 0 { 190 | return b 191 | } else { 192 | return a 193 | } 194 | } 195 | 196 | newmeta := &Metadata{} 197 | newmeta.Title = selct(m.Title, other.Base) 198 | newmeta.Title = selct(m.Title, other.Title) 199 | newmeta.Description = selct(m.Description, other.Description) 200 | newmeta.AppName = selct(m.AppName, other.AppName) 201 | newmeta.Authors = selctslc(m.Authors, other.Authors) 202 | newmeta.Generator = selct(m.Generator, other.Generator) 203 | newmeta.Keywords = selctslc(m.Keywords, other.Keywords) 204 | newmeta.Referrer = selct(m.Referrer, other.Referrer) 205 | newmeta.Creator = selct(m.Creator, other.Creator) 206 | newmeta.Publisher = selct(m.Publisher, other.Publisher) 207 | newmeta.Robots = selct(m.Robots, other.Robots) 208 | newmeta.Manifest = selct(m.Manifest, other.Manifest) 209 | newmeta.Icons = selct(m.Icons, other.Icons) 210 | newmeta.Language = selct(m.Language, other.Language) 211 | newmeta.Alternates = struct { 212 | Canonical string 213 | Languages []string 214 | Media string 215 | Types []string 216 | }{ 217 | Canonical: selct(m.Alternates.Canonical, other.Alternates.Canonical), 218 | Languages: selctslc(m.Alternates.Languages, other.Alternates.Languages), 219 | Media: selct(m.Alternates.Media, other.Alternates.Media), 220 | Types: selctslc(m.Alternates.Types, other.Alternates.Types), 221 | } 222 | if m.Twitter != nil { 223 | newmeta.Twitter = m.Twitter 224 | } else { 225 | newmeta.Twitter = other.Twitter 226 | } 227 | if m.OpenGraph != nil { 228 | newmeta.OpenGraph = m.OpenGraph 229 | } else { 230 | newmeta.OpenGraph = other.OpenGraph 231 | } 232 | newmeta.Assets = selctslc(m.Assets, other.Assets) 233 | 234 | return newmeta 235 | } 236 | 237 | func (m *Metadata) Render(ctx context.Context, w io.Writer) error { 238 | var title string = "Pacis App" 239 | if len(m.Title) > 0 { 240 | title = m.Title 241 | } 242 | 243 | el := html.Frag( 244 | html.Title(html.Text(title)), 245 | html.Meta(html.Charset("UTF-8")), 246 | html.Meta(html.HttpEquiv("Content-Type"), html.Content("text/html; charset=utf-8")), 247 | html.Meta(html.Name("title"), html.Content(title)), 248 | html.Meta(html.Name("viewport"), html.Content("width=device-width, initial-scale=1.0")), 249 | 250 | html.If( 251 | len(m.Description) > 0, 252 | html.Meta(html.Name("description"), html.Content(m.Description)), 253 | ), 254 | html.If( 255 | len(m.Keywords) > 0, 256 | html.Meta(html.Name("keywords"), html.Content(strings.Join(m.Keywords, ","))), 257 | ), 258 | html.If( 259 | len(m.Robots) > 0, 260 | html.Meta(html.Name("robots"), html.Content(m.Robots)), 261 | ), 262 | html.Map(m.Authors, func(author string, i int) html.I { 263 | return html.Meta(html.Name("author"), html.Content(author)) 264 | }), 265 | html.IfFn(m.Twitter != nil, func() html.Renderer { 266 | return html.Frag( 267 | html.If( 268 | len(m.Twitter.Card) > 0, 269 | html.Meta(html.Property("twitter:card"), html.Content(m.Twitter.Card)), 270 | ), 271 | html.If( 272 | len(m.Twitter.Title) > 0, 273 | html.Meta(html.Property("twitter:title"), html.Content(m.Twitter.Title)), 274 | ), 275 | html.If( 276 | len(m.Twitter.Description) > 0, 277 | html.Meta(html.Property("twitter:description"), html.Content(m.Twitter.Description)), 278 | ), 279 | html.If( 280 | len(m.Twitter.URL) > 0, 281 | html.Meta(html.Property("twitter:url"), html.Content(m.Twitter.URL)), 282 | ), 283 | html.If( 284 | len(m.Twitter.Image) > 0, 285 | html.Meta(html.Property("twitter:image"), html.Content(m.Twitter.Image)), 286 | ), 287 | ) 288 | }), 289 | html.IfFn(m.OpenGraph != nil, func() html.Renderer { 290 | return html.Frag( 291 | html.If( 292 | len(m.OpenGraph.Type) > 0, 293 | html.Meta(html.Property("og:type"), html.Content(m.OpenGraph.Type)), 294 | ), 295 | html.If( 296 | len(m.OpenGraph.URL) > 0, 297 | html.Meta(html.Property("og:url"), html.Content(m.OpenGraph.URL)), 298 | ), 299 | html.If( 300 | len(m.OpenGraph.Title) > 0, 301 | html.Meta(html.Property("og:title"), html.Content(m.OpenGraph.Title)), 302 | ), 303 | html.If( 304 | len(m.OpenGraph.Description) > 0, 305 | html.Meta(html.Property("og:description"), html.Content(m.OpenGraph.Description)), 306 | ), 307 | html.If( 308 | len(m.OpenGraph.Image) > 0, 309 | html.Meta(html.Property("og:image"), html.Content(m.OpenGraph.Image)), 310 | ), 311 | ) 312 | }), 313 | ) 314 | 315 | return el.Render(ctx, w) 316 | } 317 | 318 | func (m *Metadata) NodeType() html.NodeType { 319 | return html.NodeFragment 320 | } 321 | 322 | func SetMetadata(ctx *Context, data *Metadata) { 323 | if ctx.head.meta == nil { 324 | ctx.head.meta = data 325 | return 326 | } 327 | ctx.head.meta = ctx.head.meta.Merge(data) 328 | } 329 | 330 | var pagemetadata = map[string]*Metadata{} 331 | 332 | func SetPageMetadata(path string, data *Metadata) string { 333 | pagemetadata[path] = data 334 | return path 335 | } 336 | -------------------------------------------------------------------------------- /pages/font/font.go: -------------------------------------------------------------------------------- 1 | package fonts 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | h "github.com/canpacis/pacis/ui/html" 8 | ) 9 | 10 | type Weight int 11 | 12 | const ( 13 | _ = Weight(iota) 14 | W100 = Weight(iota * 100) 15 | W200 16 | W300 17 | W400 18 | W500 19 | W600 20 | W700 21 | W800 22 | W900 23 | ) 24 | 25 | func (w Weight) String() string { 26 | return fmt.Sprintf("%d", w) 27 | } 28 | 29 | type WeightList []Weight 30 | 31 | func (wl WeightList) String() string { 32 | min := 1000 33 | max := 0 34 | 35 | for _, weight := range wl { 36 | if weight < Weight(min) { 37 | min = int(weight) 38 | } 39 | if weight > Weight(max) { 40 | max = int(weight) 41 | } 42 | } 43 | 44 | return fmt.Sprintf("%d..%d", min, max) 45 | } 46 | 47 | type Subset string 48 | 49 | const ( 50 | Latin = Subset("latin") 51 | LatinExt = Subset("latin-ext") 52 | Cyrillic = Subset("cyrillic") 53 | ) 54 | 55 | type Display string 56 | 57 | const ( 58 | Swap = Display("swap") 59 | Auto = Display("auto") 60 | Block = Display("block") 61 | Fallback = Display("fallback") 62 | Optional = Display("optional") 63 | ) 64 | 65 | type Font struct { 66 | Name string 67 | WeightList WeightList 68 | Subsets []Subset 69 | Display Display 70 | // TODO: Optical sizing and default weight list features 71 | } 72 | 73 | func (f Font) URL() string { 74 | return fmt.Sprintf( 75 | "https://fonts.googleapis.com/css2?family=%s:wght@%s&display=%s", 76 | strings.ReplaceAll(f.Name, " ", "+"), 77 | f.WeightList.String(), 78 | f.Display, 79 | ) 80 | } 81 | 82 | func New(name string, weights WeightList, display Display, subsets ...Subset) *Font { 83 | return &Font{ 84 | Name: name, 85 | WeightList: weights, 86 | Subsets: subsets, 87 | Display: display, 88 | } 89 | } 90 | 91 | func Head(fonts ...*Font) *h.Fragment { 92 | return h.Frag( 93 | h.Link(h.Href("https://fonts.googleapis.com"), h.Rel("preconnect")), 94 | h.Link(h.Href("https://fonts.gstatic.com"), h.Rel("preconnect")), 95 | h.Map(fonts, func(font *Font, i int) h.I { 96 | return h.Link(h.Href(font.URL()), h.Rel("stylesheet")) 97 | }), 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /pages/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/canpacis/pacis/pages 2 | 3 | go 1.24.1 4 | 5 | require ( 6 | github.com/NYTimes/gziphandler v1.1.1 7 | github.com/canpacis/pacis/ui v0.0.0-20250505100844-16cacf133344 8 | github.com/canpacis/scanner v0.1.3 9 | github.com/evanw/esbuild v0.25.2 10 | github.com/google/uuid v1.6.0 11 | github.com/nicksnyder/go-i18n/v2 v2.5.1 12 | github.com/urfave/cli/v2 v2.27.6 13 | golang.org/x/text v0.23.0 14 | ) 15 | 16 | require ( 17 | github.com/alecthomas/chroma/v2 v2.17.0 // indirect 18 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 19 | github.com/dlclark/regexp2 v1.11.5 // indirect 20 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 21 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 22 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /pages/go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 2 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 | github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= 4 | github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= 5 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 6 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 7 | github.com/alecthomas/chroma/v2 v2.17.0 h1:3r2Cgk+nXNICMBxIFGnTRTbQFUwMiLisW+9uos0TtUI= 8 | github.com/alecthomas/chroma/v2 v2.17.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 9 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 10 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 11 | github.com/canpacis/pacis/ui v0.0.0-20250505100844-16cacf133344 h1:qeYYslwxIh1xh/9Ky+G4/kzy5zGbddsB785nHA5l+WA= 12 | github.com/canpacis/pacis/ui v0.0.0-20250505100844-16cacf133344/go.mod h1:/pIKUjtMSPyZo9BjUA0Edmenes0hyXzyQZz1j4b2jqM= 13 | github.com/canpacis/scanner v0.1.3 h1:du3lEZhbaemoNfmuUt688VSrBpopD/zKlzXepPJlW4Y= 14 | github.com/canpacis/scanner v0.1.3/go.mod h1:QOJsuG5EPFmwfZ98J9Q2/Rf6CJGDI/ZXDVDgzoTiM0k= 15 | github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 16 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 21 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 22 | github.com/evanw/esbuild v0.25.2 h1:ublSEmZSjzOc6jLO1OTQy/vHc1wiqyDF4oB3hz5sM6s= 23 | github.com/evanw/esbuild v0.25.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48= 24 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 25 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 26 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 27 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 28 | github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= 29 | github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= 30 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 31 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 32 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 33 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 34 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 35 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 36 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 37 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 38 | github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= 39 | github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 40 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 41 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 42 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= 43 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 45 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 46 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 47 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 48 | -------------------------------------------------------------------------------- /pages/i18n/i18n.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | p "path" 10 | 11 | "github.com/canpacis/pacis/pages/internal" 12 | c "github.com/canpacis/pacis/ui/components" 13 | h "github.com/canpacis/pacis/ui/html" 14 | "github.com/nicksnyder/go-i18n/v2/i18n" 15 | "golang.org/x/text/language" 16 | ) 17 | 18 | func Setup(dir fs.FS, defaultlang language.Tag) (*i18n.Bundle, error) { 19 | paths := []string{} 20 | err := fs.WalkDir(dir, ".", func(path string, d fs.DirEntry, err error) error { 21 | if err != nil { 22 | return err 23 | } 24 | 25 | if p.Ext(d.Name()) != ".json" { 26 | return nil 27 | } 28 | 29 | paths = append(paths, path) 30 | return nil 31 | }) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | bundle := i18n.NewBundle(defaultlang) 37 | for _, path := range paths { 38 | _, err = bundle.LoadMessageFileFS(dir, path) 39 | if err != nil { 40 | return nil, err 41 | } 42 | } 43 | 44 | return bundle, nil 45 | } 46 | 47 | type Message struct { 48 | key string 49 | data any 50 | } 51 | 52 | func (m Message) Render(ctx context.Context, w io.Writer) error { 53 | localizer := internal.Get[*i18n.Localizer](ctx, "localizer") 54 | if localizer == nil { 55 | return c.ErrorText(fmt.Errorf("no localizer in the context, have you registered the i18n middleware correctly?")).Render(ctx, w) 56 | } 57 | 58 | message, err := localizer.Localize(&i18n.LocalizeConfig{ 59 | MessageID: m.key, 60 | TemplateData: m.data, 61 | }) 62 | if err != nil { 63 | return c.ErrorText(err).Render(ctx, w) 64 | } 65 | return h.Text(message).Render(ctx, w) 66 | } 67 | 68 | func (Message) NodeType() h.NodeType { 69 | return h.NodeText 70 | } 71 | 72 | func (m Message) String(ctx context.Context) string { 73 | buf := bytes.NewBuffer([]byte{}) 74 | if err := m.Render(ctx, buf); err != nil { 75 | buf.Reset() 76 | c.ErrorText(err).Render(ctx, buf) 77 | } 78 | return buf.String() 79 | } 80 | 81 | func Text(key string, data ...any) Message { 82 | var d any 83 | if len(data) > 0 { 84 | d = data[0] 85 | } 86 | 87 | return Message{key: key, data: d} 88 | } 89 | 90 | func Locale(ctx context.Context) (*language.Tag, error) { 91 | locale := internal.Get[*language.Tag](ctx, "locale") 92 | if locale == nil { 93 | return nil, fmt.Errorf("no localizer in the context, have you registered the i18n middleware correctly?") 94 | } 95 | 96 | return locale, nil 97 | } 98 | -------------------------------------------------------------------------------- /pages/internal/internal.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/canpacis/pacis/ui/html" 12 | "github.com/canpacis/scanner/structd" 13 | ) 14 | 15 | type ctxkey string 16 | 17 | func Set(ctx context.Context, key string, value any) context.Context { 18 | return context.WithValue(ctx, ctxkey(fmt.Sprintf("%s:%s", "pacis", key)), value) 19 | } 20 | 21 | func Get[T any](ctx context.Context, key string) T { 22 | value := ctx.Value(ctxkey(fmt.Sprintf("%s:%s", "pacis", key))) 23 | cast, ok := value.(T) 24 | if !ok { 25 | var v T 26 | log.Fatalf("failed to cast ctx key '%s' to %T\n", key, v) 27 | return v 28 | } 29 | return cast 30 | } 31 | 32 | type ContextScanner struct { 33 | ctx context.Context 34 | } 35 | 36 | func (s *ContextScanner) Scan(v any) error { 37 | return structd.New(s, "context").Decode(v) 38 | } 39 | 40 | func (s *ContextScanner) Get(key string) any { 41 | return Get[any](s.ctx, key) 42 | } 43 | 44 | func NewContextScanner(ctx context.Context) *ContextScanner { 45 | return &ContextScanner{ctx: ctx} 46 | } 47 | 48 | type StreamWriter struct { 49 | Renderer html.I 50 | 51 | buf *bytes.Buffer 52 | chunksize int 53 | w http.ResponseWriter 54 | f http.Flusher 55 | } 56 | 57 | func (s *StreamWriter) Write(p []byte) (int, error) { 58 | if s.buf.Len() < s.chunksize { 59 | return s.buf.Write(p) 60 | } 61 | 62 | n, err := s.buf.Write(p) 63 | if err != nil { 64 | return n, err 65 | } 66 | m, err := io.Copy(s.w, s.buf) 67 | s.f.Flush() 68 | s.buf.Reset() 69 | return int(m), err 70 | } 71 | 72 | func (s *StreamWriter) Flush() { 73 | if s.buf.Len() != 0 { 74 | io.Copy(s.w, s.buf) 75 | s.f.Flush() 76 | s.buf.Reset() 77 | } 78 | } 79 | 80 | func NewStreamWriter(renderer html.I, w http.ResponseWriter) *StreamWriter { 81 | return &StreamWriter{ 82 | Renderer: renderer, 83 | buf: new(bytes.Buffer), 84 | chunksize: 1024, 85 | w: w, 86 | f: w.(http.Flusher), 87 | } 88 | } 89 | 90 | func Render(ctx context.Context, sw *StreamWriter) error { 91 | sw.w.Header().Set("Content-Type", "text/html") 92 | if err := sw.Renderer.Render(ctx, sw); err != nil { 93 | return err 94 | } 95 | sw.Flush() 96 | 97 | // TODO: Async streaming 98 | // // TODO: Maybe carry this to the hooks api 99 | // size := int(ctx.chsize.Load()) 100 | // if size == 0 { 101 | // return 102 | // } 103 | 104 | // ctx.elemch = make(chan h.Element, size) 105 | // ctx.ready.Store(true) 106 | 107 | // for range size { 108 | // select { 109 | // case <-ctx.Done(): 110 | // // client disconnected 111 | // return 112 | // case el := <-ctx.elemch: 113 | // el.Render(ctx, w) 114 | // s.Flush() 115 | // } 116 | // } 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /pages/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/NYTimes/gziphandler" 11 | "github.com/canpacis/pacis/pages/internal" 12 | "github.com/google/uuid" 13 | "github.com/nicksnyder/go-i18n/v2/i18n" 14 | "golang.org/x/text/language" 15 | ) 16 | 17 | func Theme(h http.Handler) http.Handler { 18 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | set := func(theme string) { 20 | http.SetCookie(w, &http.Cookie{ 21 | Name: "pacis_color_scheme", 22 | Value: theme, 23 | Path: "/", 24 | HttpOnly: false, 25 | Secure: false, 26 | SameSite: http.SameSiteLaxMode, 27 | }) 28 | } 29 | 30 | cookie, err := r.Cookie("pacis_color_scheme") 31 | var theme string 32 | if err == nil { 33 | switch cookie.Value { 34 | case "light", "dark": 35 | theme = cookie.Value 36 | default: 37 | theme = "light" 38 | set(theme) 39 | } 40 | } else { 41 | theme = "light" 42 | set(theme) 43 | } 44 | 45 | ctx := r.Context() 46 | ctx = internal.Set(ctx, "theme", theme) 47 | 48 | h.ServeHTTP(w, r.Clone(ctx)) 49 | }) 50 | } 51 | 52 | func Locale(bundle *i18n.Bundle, defaultlang language.Tag) func(http.Handler) http.Handler { 53 | return func(h http.Handler) http.Handler { 54 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 55 | var locale string 56 | cookie, err := r.Cookie("pacis_locale") 57 | if err == nil { 58 | locale = cookie.Value 59 | } else { 60 | header := r.Header.Get("Accept-Language") 61 | switch { 62 | case len(r.FormValue("lang")) > 0: 63 | locale = r.FormValue("lang") 64 | case len(header) > 0: 65 | locale = strings.Split(header, ",")[0] 66 | default: 67 | locale = defaultlang.String() 68 | } 69 | } 70 | tag, err := language.Parse(locale) 71 | if err != nil { 72 | tag = defaultlang 73 | } 74 | 75 | localizer := i18n.NewLocalizer(bundle, tag.String()) 76 | ctx := r.Context() 77 | ctx = internal.Set(ctx, "localizer", localizer) 78 | ctx = internal.Set(ctx, "locale", &tag) 79 | h.ServeHTTP(w, r.Clone(ctx)) 80 | }) 81 | } 82 | } 83 | 84 | func Cache(duration time.Duration) func(http.Handler) http.Handler { 85 | return func(h http.Handler) http.Handler { 86 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 87 | w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int64(duration.Seconds()))) 88 | h.ServeHTTP(w, r) 89 | }) 90 | } 91 | } 92 | 93 | var Gzip = gziphandler.GzipHandler 94 | 95 | type statusRecorder struct { 96 | http.ResponseWriter 97 | http.Flusher 98 | status int 99 | } 100 | 101 | func (r *statusRecorder) WriteHeader(statusCode int) { 102 | r.status = statusCode 103 | r.ResponseWriter.WriteHeader(statusCode) 104 | } 105 | 106 | func Logger(next http.Handler) http.Handler { 107 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 108 | start := internal.Get[time.Time](r.Context(), "start") 109 | reqid := internal.Get[uuid.UUID](r.Context(), "request_id") 110 | 111 | rec := &statusRecorder{ResponseWriter: w, Flusher: w.(http.Flusher), status: http.StatusOK} 112 | next.ServeHTTP(rec, r) 113 | 114 | slog.Info( 115 | "request", 116 | slog.String("request_id", reqid.String()), 117 | slog.Duration("duration", time.Since(start)), 118 | slog.String("method", r.Method), 119 | slog.Int("status", rec.status), 120 | slog.String("path", r.URL.Path), 121 | slog.String("addr", r.RemoteAddr), 122 | slog.String("agent", r.UserAgent()), 123 | ) 124 | }) 125 | } 126 | 127 | func Tracer(next http.Handler) http.Handler { 128 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 129 | start := time.Now() 130 | reqid := uuid.New() 131 | 132 | ctx := r.Context() 133 | ctx = internal.Set(ctx, "request_id", reqid) 134 | ctx = internal.Set(ctx, "start", start) 135 | 136 | next.ServeHTTP(w, r.Clone(ctx)) 137 | }) 138 | } 139 | 140 | type User interface { 141 | ID() string 142 | } 143 | 144 | type AuthHandler[T User] func(*http.Request) (T, error) 145 | 146 | func Authentication[T User](handler AuthHandler[T]) func(http.Handler) http.Handler { 147 | return func(next http.Handler) http.Handler { 148 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 149 | // TODO 150 | user, _ := handler(r) 151 | // if err != nil { 152 | // reqid := internal.Get[uuid.UUID](r.Context(), "request_id") 153 | // logger := slog.With( 154 | // slog.String("error", err.Error()), 155 | // ) 156 | // if reqid != nil { 157 | // logger = logger.With(slog.String("request_id", reqid.String())) 158 | // } 159 | // logger.Error("failed to run authentication handler") 160 | // } 161 | ctx := r.Context() 162 | ctx = internal.Set(ctx, "user", user) 163 | next.ServeHTTP(w, r.Clone(ctx)) 164 | }) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /pages/pcpg/compile.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "embed" 6 | "encoding/hex" 7 | "errors" 8 | "hash/adler32" 9 | "io" 10 | "os" 11 | "os/exec" 12 | "path" 13 | "strings" 14 | 15 | "github.com/canpacis/pacis/ui/components" 16 | "github.com/evanw/esbuild/pkg/api" 17 | ) 18 | 19 | func hash(src []byte, prefix, suffix string) string { 20 | hasher := adler32.New() 21 | hasher.Write(src) 22 | return prefix + hex.EncodeToString(hasher.Sum(nil)) + suffix 23 | } 24 | 25 | type dirconfig struct { 26 | root string 27 | app string 28 | messages string 29 | assets string 30 | static string 31 | } 32 | 33 | func setupdir(target string) (*dirconfig, error) { 34 | wd, err := os.Getwd() 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | root := path.Join(wd, target) 40 | app := path.Join(root, "app") 41 | cfg := &dirconfig{ 42 | root: root, 43 | app: app, 44 | messages: path.Join(app, "messages"), 45 | assets: path.Join(app, "assets"), 46 | static: path.Join(app, "static"), 47 | } 48 | return cfg, nil 49 | } 50 | 51 | func compile(target string) (map[string]string, error) { 52 | dircfg, err := setupdir(target) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | os.RemoveAll(dircfg.static) 58 | os.Mkdir(dircfg.static, 0o755) 59 | 60 | assets, assetmap, err := createassets(dircfg) 61 | if err != nil { 62 | return nil, err 63 | } 64 | for _, asset := range assets { 65 | file, err := os.OpenFile(asset.name, os.O_CREATE|os.O_RDWR, 0o644) 66 | if err != nil { 67 | return nil, err 68 | } 69 | defer file.Close() 70 | if _, err := file.Write(asset.content); err != nil { 71 | return nil, err 72 | } 73 | } 74 | return assetmap, nil 75 | } 76 | 77 | type asset struct { 78 | base string 79 | name string 80 | content []byte 81 | } 82 | 83 | //go:embed static 84 | var staticfs embed.FS 85 | 86 | func createassets(dircfg *dirconfig) ([]asset, map[string]string, error) { 87 | assets := []asset{} 88 | assetmap := map[string]string{} 89 | 90 | static := func(str string) string { 91 | return "/static/" + str 92 | } 93 | 94 | var name string 95 | 96 | script := components.AppScript() 97 | name = hash(script, "app_", ".js") 98 | assets = append(assets, asset{name, path.Join(dircfg.static, name), script}) 99 | assetmap["app.ts"] = static(name) 100 | 101 | style := components.AppStyle() 102 | 103 | entries, err := os.ReadDir(dircfg.assets) 104 | if err != nil { 105 | return nil, nil, err 106 | } 107 | 108 | for _, entry := range entries { 109 | name := entry.Name() 110 | ext := path.Ext(name) 111 | base, _ := strings.CutSuffix(name, ext) 112 | 113 | switch ext { 114 | case ".ts": 115 | // if name != "main.ts" { 116 | // continue 117 | // } 118 | result := api.Build(api.BuildOptions{ 119 | EntryPoints: []string{path.Join(dircfg.assets, name)}, 120 | Bundle: true, 121 | Write: false, 122 | }) 123 | 124 | if len(result.Errors) != 0 { 125 | return nil, nil, errors.New(result.Errors[0].Text) 126 | } 127 | raw := result.OutputFiles[0].Contents 128 | old := name 129 | oldbase, _ := strings.CutSuffix(old, path.Ext(old)) 130 | name = hash(raw, oldbase+"_", ".js") 131 | assets = append(assets, asset{name, path.Join(dircfg.static, name), raw}) 132 | assetmap[old] = static(name) 133 | case ".css": 134 | if name != "main.css" { 135 | continue 136 | } 137 | raw, err := os.ReadFile(path.Join(dircfg.assets, name)) 138 | if err != nil { 139 | return nil, nil, err 140 | } 141 | generated, err := stdiotailwind(raw) 142 | if err != nil { 143 | return nil, nil, err 144 | } 145 | style = append(style, 10) 146 | style = append(style, generated...) 147 | default: 148 | raw, err := os.ReadFile(path.Join(dircfg.assets, name)) 149 | if err != nil { 150 | return nil, nil, err 151 | } 152 | old := name 153 | name = hash(raw, base+"_", ext) 154 | assets = append(assets, asset{base, path.Join(dircfg.static, name), raw}) 155 | assetmap[old] = static(name) 156 | } 157 | } 158 | 159 | // TODO: find a solution for local static assets 160 | entries, err = staticfs.ReadDir("static") 161 | if err != nil { 162 | return nil, nil, err 163 | } 164 | 165 | for _, entry := range entries { 166 | name := entry.Name() 167 | ext := path.Ext(name) 168 | base, _ := strings.CutSuffix(name, ext) 169 | 170 | raw, err := staticfs.ReadFile(path.Join("static", name)) 171 | if err != nil { 172 | return nil, nil, err 173 | } 174 | old := name 175 | name = hash(raw, base+"_", ext) 176 | assets = append(assets, asset{base, path.Join(dircfg.static, name), raw}) 177 | assetmap[old] = static(name) 178 | } 179 | 180 | name = hash(style, "main_", ".css") 181 | assets = append(assets, asset{name, path.Join(dircfg.static, name), style}) 182 | assetmap["main.css"] = static(name) 183 | 184 | return assets, assetmap, nil 185 | } 186 | 187 | func stdiotailwind(src []byte) ([]byte, error) { 188 | inbuf := bytes.NewBuffer(src) 189 | 190 | infile, err := os.CreateTemp("", "input-*") 191 | if err != nil { 192 | return nil, err 193 | } 194 | defer os.Remove(infile.Name()) 195 | defer infile.Close() 196 | 197 | if _, err := io.Copy(infile, inbuf); err != nil { 198 | return nil, err 199 | } 200 | infile.Sync() 201 | 202 | outfile, err := os.CreateTemp("", "output-*") 203 | if err != nil { 204 | return nil, err 205 | } 206 | defer os.Remove(outfile.Name()) 207 | defer outfile.Close() 208 | 209 | dir := getInstallPath() 210 | cmd := exec.Command(path.Join(dir, "pcpg_tw"), "-i", infile.Name(), "-o", outfile.Name(), "-m") 211 | cmd.Stderr = os.Stderr 212 | if err := cmd.Run(); err != nil { 213 | return nil, err 214 | } 215 | if _, err := outfile.Seek(0, io.SeekStart); err != nil { 216 | return nil, err 217 | } 218 | 219 | outbuf := new(bytes.Buffer) 220 | if _, err := io.Copy(outbuf, outfile); err != nil { 221 | return nil, err 222 | } 223 | 224 | return outbuf.Bytes(), nil 225 | } 226 | -------------------------------------------------------------------------------- /pages/pcpg/install.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "runtime" 11 | "strings" 12 | ) 13 | 14 | func getOsArch() (string, string) { 15 | os := runtime.GOOS 16 | arch := runtime.GOARCH 17 | 18 | switch os { 19 | case "darwin": 20 | os = "macos" 21 | } 22 | 23 | switch arch { 24 | case "amd64": 25 | arch = "x64" 26 | } 27 | return os, arch 28 | } 29 | 30 | func getInstallPath() string { 31 | ros, _ := getOsArch() 32 | var dir string 33 | if ros == "windows" { 34 | dir = filepath.Join(os.Getenv("PROGRAMFILES"), "pcpg") 35 | } else { 36 | dir = "/usr/local/bin" 37 | } 38 | return dir 39 | } 40 | 41 | func install() error { 42 | ros, arch := getOsArch() 43 | dir := getInstallPath() 44 | 45 | setup := func() error { 46 | // Create install directory if it doesn't exist 47 | os.MkdirAll(dir, 0755) 48 | 49 | // Remove old artifacts 50 | entries, err := os.ReadDir(dir) 51 | if err != nil { 52 | return fmt.Errorf("failed to remove artifacts: %w", err) 53 | } 54 | for _, entry := range entries { 55 | name := entry.Name() 56 | if strings.HasPrefix(name, "pcpg_") { 57 | os.Remove(path.Join(dir, name)) 58 | } 59 | } 60 | return nil 61 | } 62 | 63 | if err := setup(); err != nil { 64 | return err 65 | } 66 | 67 | savebin := func(url, name string) error { 68 | fmt.Printf("downloading binary %s from %s\n", name, url) 69 | 70 | resp, err := http.Get(url) 71 | if err != nil { 72 | return fmt.Errorf("failed to download binary: %w", err) 73 | } 74 | defer resp.Body.Close() 75 | fmt.Println("download complete") 76 | 77 | binpath := filepath.Join(dir, name) 78 | if ros == "windows" { 79 | binpath += ".exe" 80 | } 81 | 82 | out, err := os.Create(binpath) 83 | if err != nil { 84 | return fmt.Errorf("failed to create output file: %w", err) 85 | } 86 | defer out.Close() 87 | 88 | _, err = io.Copy(out, resp.Body) 89 | if err != nil { 90 | return fmt.Errorf("failed to save binary: %w", err) 91 | } 92 | 93 | if ros != "windows" { 94 | err = os.Chmod(binpath, 0755) 95 | if err != nil { 96 | fmt.Println("Failed to make binary executable:", err) 97 | os.Exit(1) 98 | } 99 | } 100 | 101 | return nil 102 | } 103 | 104 | url := fmt.Sprintf("https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-%s-%s", ros, arch) 105 | if err := savebin(url, "pcpg_tw"); err != nil { 106 | return err 107 | } 108 | 109 | // potentially more binaries will come here 110 | fmt.Println("install complete") 111 | return nil 112 | } 113 | -------------------------------------------------------------------------------- /pages/pcpg/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "os" 7 | "path" 8 | 9 | "github.com/canpacis/pacis/pages/pcpg/generator" 10 | pparser "github.com/canpacis/pacis/pages/pcpg/parser" 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | func main() { 15 | app := &cli.App{ 16 | Name: "pcpg", 17 | Usage: "pacis pages cli for bundling your assets and managing binaries for tooling", 18 | Commands: []*cli.Command{ 19 | { 20 | Name: "compile", 21 | Aliases: []string{"c"}, 22 | Usage: "bundle your assets and compile your go code to create a router", 23 | Action: func(ctx *cli.Context) error { 24 | if !ctx.Args().Present() { 25 | return errors.New("a root directory is required for compiling") 26 | } 27 | 28 | root := ctx.Args().First() 29 | wd, err := os.Getwd() 30 | if err != nil { 31 | return err 32 | } 33 | absroot := path.Join(wd, root) 34 | 35 | list, err := pparser.ParseDir(path.Join(root, "app")) 36 | if err != nil { 37 | return err 38 | } 39 | assets, err := compile(root) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | file, err := generator.CreateFile(list, assets) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | content, err := generator.GenerateFile(file) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | app := path.Join(absroot, "app") 55 | return os.WriteFile(path.Join(app, "app.gen.go"), content, 0o644) 56 | }, 57 | }, 58 | { 59 | Name: "install", 60 | Aliases: []string{"i"}, 61 | Usage: "install/update dependencies for pacis pages cli", 62 | Action: func(ctx *cli.Context) error { 63 | return install() 64 | }, 65 | }, 66 | }, 67 | } 68 | 69 | err := app.Run(os.Args) 70 | if err != nil { 71 | log.Fatal(err) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pages/pcpg/parser/parser.go: -------------------------------------------------------------------------------- 1 | package parser 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "go/ast" 7 | "go/parser" 8 | "go/token" 9 | "iter" 10 | "slices" 11 | "strings" 12 | ) 13 | 14 | var ( 15 | ErrNotAPacisDirective = errors.New("given value is not a pacis directive") 16 | ErrInvalidDirectiveType = errors.New("invalid directive type") 17 | ErrInvalidDireciveParams = errors.New("invalid directive params") 18 | ErrMissingDirectiveParam = errors.New("directive is missing a parameter") 19 | ) 20 | 21 | const ( 22 | dirprefix = "//pacis:" 23 | ) 24 | 25 | type DirectiveType int 26 | 27 | const ( 28 | InvalidDirective = DirectiveType(iota) 29 | PageDirective 30 | LayoutDirective 31 | RedirectDirective 32 | ActionDirective 33 | MiddlewareDirective 34 | LanguageDirective 35 | AuthenticationDirective 36 | ) 37 | 38 | func (dt DirectiveType) String() string { 39 | switch dt { 40 | case PageDirective: 41 | return "page" 42 | case LayoutDirective: 43 | return "layout" 44 | case RedirectDirective: 45 | return "redirect" 46 | case ActionDirective: 47 | return "action" 48 | case MiddlewareDirective: 49 | return "middleware" 50 | case LanguageDirective: 51 | return "language" 52 | case AuthenticationDirective: 53 | return "authentication" 54 | default: 55 | return "unknown" 56 | } 57 | } 58 | 59 | var validdirs = []string{"page", "layout", "redirect", "action", "middleware", "language", "authentication"} 60 | 61 | type Directive struct { 62 | Type DirectiveType 63 | Params map[string]string 64 | Position token.Position 65 | Node ast.Decl 66 | } 67 | 68 | func parseparams(list []string) (map[string]string, error) { 69 | params := make(map[string]string) 70 | 71 | for _, item := range list { 72 | parts := strings.Split(item, "=") 73 | if len(parts) != 2 { 74 | return nil, errors.New("invalid number of param parts") 75 | } 76 | params[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) 77 | } 78 | return params, nil 79 | } 80 | 81 | func ParseComment(comment string, pos token.Position, decl ast.Decl) (*Directive, error) { 82 | after, ok := strings.CutPrefix(comment, dirprefix) 83 | if !ok { 84 | return nil, ErrNotAPacisDirective 85 | } 86 | parts := strings.Split(after, " ") 87 | if len(parts) == 0 { 88 | return nil, ErrInvalidDirectiveType 89 | } 90 | 91 | if !slices.Contains(validdirs, parts[0]) { 92 | return nil, fmt.Errorf("%w: %s", ErrInvalidDirectiveType, parts[0]) 93 | } 94 | 95 | var typ DirectiveType 96 | switch parts[0] { 97 | case "page": 98 | typ = PageDirective 99 | case "layout": 100 | typ = LayoutDirective 101 | case "redirect": 102 | typ = RedirectDirective 103 | case "action": 104 | typ = ActionDirective 105 | case "middleware": 106 | typ = MiddlewareDirective 107 | case "language": 108 | typ = LanguageDirective 109 | case "authentication": 110 | typ = AuthenticationDirective 111 | default: 112 | typ = InvalidDirective 113 | } 114 | 115 | params := map[string]string{} 116 | if len(parts) >= 2 { 117 | var err error 118 | params, err = parseparams(parts[1:]) 119 | if err != nil { 120 | return nil, fmt.Errorf("%w: %w", ErrInvalidDireciveParams, err) 121 | } 122 | } 123 | 124 | return &Directive{ 125 | Type: typ, 126 | Params: params, 127 | Position: pos, 128 | Node: decl, 129 | }, nil 130 | } 131 | 132 | type AstIter struct { 133 | file *ast.File 134 | } 135 | 136 | func (ai *AstIter) Comments() iter.Seq[*ast.CommentGroup] { 137 | return func(yield func(*ast.CommentGroup) bool) { 138 | var stopped bool 139 | ast.Inspect(ai.file, func(n ast.Node) bool { 140 | if stopped || n == nil { 141 | return false 142 | } 143 | comgrp, ok := n.(*ast.CommentGroup) 144 | if ok { 145 | if !yield(comgrp) { 146 | stopped = true 147 | } 148 | } 149 | return true 150 | }) 151 | } 152 | } 153 | 154 | type DirectiveList struct { 155 | Page []*Directive 156 | Action []*Directive 157 | Layout []*Directive 158 | Redirect []*Directive 159 | Middleware []*Directive 160 | Language []*Directive 161 | Authentication []*Directive 162 | } 163 | 164 | func decl(pos token.Pos, file *ast.File) ast.Decl { 165 | for _, dcl := range file.Decls { 166 | fn, ok := dcl.(*ast.FuncDecl) 167 | if !ok { 168 | gen, ok := dcl.(*ast.GenDecl) 169 | if !ok { 170 | continue 171 | } 172 | if gen.Tok != token.VAR && gen.Tok != token.CONST && gen.Tok != token.FUNC { 173 | continue 174 | } 175 | if gen.Doc == nil || len(gen.Doc.List) == 0 { 176 | continue 177 | } 178 | } else { 179 | if fn.Doc == nil || len(fn.Doc.List) == 0 { 180 | continue 181 | } 182 | } 183 | // Get start and end position of the declaration 184 | start := dcl.Pos() 185 | end := dcl.End() 186 | 187 | if pos >= start && pos <= end { 188 | return dcl 189 | } 190 | } 191 | return nil 192 | } 193 | 194 | func ParseDir(dir string) (*DirectiveList, error) { 195 | fset := token.NewFileSet() 196 | 197 | pkgs, err := parser.ParseDir(fset, dir, nil, parser.ParseComments) 198 | if err != nil { 199 | return nil, err 200 | } 201 | pkg, ok := pkgs["app"] 202 | if !ok { 203 | return nil, errors.New("failed to locate the app package") 204 | } 205 | 206 | list := &DirectiveList{} 207 | 208 | for _, file := range pkg.Files { 209 | iter := AstIter{file} 210 | 211 | for group := range iter.Comments() { 212 | for _, comment := range group.List { 213 | if strings.HasPrefix(comment.Text, dirprefix) { 214 | dir, err := ParseComment(comment.Text, fset.Position(comment.Pos()), decl(group.End()+1, file)) 215 | if err != nil { 216 | return nil, err 217 | } 218 | 219 | switch dir.Type { 220 | case PageDirective: 221 | list.Page = append(list.Page, dir) 222 | case LayoutDirective: 223 | list.Layout = append(list.Layout, dir) 224 | case RedirectDirective: 225 | list.Redirect = append(list.Redirect, dir) 226 | case ActionDirective: 227 | list.Action = append(list.Action, dir) 228 | case MiddlewareDirective: 229 | list.Middleware = append(list.Middleware, dir) 230 | case LanguageDirective: 231 | list.Language = append(list.Language, dir) 232 | case AuthenticationDirective: 233 | list.Authentication = append(list.Authentication, dir) 234 | } 235 | } 236 | } 237 | } 238 | } 239 | 240 | return list, nil 241 | } 242 | -------------------------------------------------------------------------------- /pages/pcpg/static/stream.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | // www/app/assets/main.ts 3 | var observer = new MutationObserver((mutations) => { 4 | for (const mutation of mutations) { 5 | if (mutation.addedNodes.length > 0) { 6 | for (const node of mutation.addedNodes) { 7 | const elem = node; 8 | const slot = elem.getAttribute("slot"); 9 | if (!slot) { 10 | throw new Error("Slotless element has been streamed to the DOM"); 11 | } 12 | document.querySelectorAll(`slot[name=${slot}]`).forEach((slot2) => { 13 | elem.setAttribute("x-show", "true"); 14 | slot2.replaceWith(elem); 15 | }); 16 | } 17 | } 18 | } 19 | }); 20 | observer.observe(document.body, { childList: true }); 21 | window.addEventListener("DOMContentLoaded", () => { 22 | observer.disconnect(); 23 | }); 24 | })(); 25 | -------------------------------------------------------------------------------- /pages/perf.go: -------------------------------------------------------------------------------- 1 | package pages 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | type ServerTiming struct { 10 | Name string 11 | Duration time.Duration 12 | Description string 13 | } 14 | 15 | func (st ServerTiming) Header() *HeaderEntry { 16 | return NewHeader("Server-Timing", st.String()) 17 | } 18 | 19 | func (st ServerTiming) String() string { 20 | builder := strings.Builder{} 21 | builder.WriteString(st.Name) 22 | 23 | if len(st.Description) != 0 { 24 | builder.WriteString(fmt.Sprintf(";desc=\"%s\"", st.Description)) 25 | } 26 | 27 | if st.Duration != 0 { 28 | builder.WriteString(fmt.Sprintf(";dur=%d", st.Duration.Milliseconds())) 29 | } 30 | return builder.String() 31 | } 32 | 33 | type Timing struct { 34 | name string 35 | desc string 36 | start time.Time 37 | } 38 | 39 | func (t Timing) Done() *ServerTiming { 40 | return &ServerTiming{Name: t.name, Description: t.desc, Duration: time.Since(t.start)} 41 | // pctx, ok := ctx.(*Context) 42 | // if ok { 43 | // pctx.timings = append(pctx.timings, st) 44 | // } else { 45 | // lctx, ok := ctx.(*Context) 46 | // if ok { 47 | // lctx.timings = append(lctx.timings, st) 48 | // } 49 | // } 50 | } 51 | 52 | func NewTiming(name, desc string) *Timing { 53 | return &Timing{start: time.Now(), name: name, desc: desc} 54 | } 55 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | PacisUI is a set of utilities that are mainly UI components that help you build beautiful web interfaces with the Go programming language. 4 | 5 | I plan to make PacisUI a part of a bigger set of tools that is Pacis, but for now I only have this documentation. 6 | 7 | ## What this is 8 | 9 | This is a UI library built with [Go](https://go.dev/), [TailwindCSS](https://tailwindcss.com/) and [Alpine.js](https://alpinejs.dev/) and it comes in two pieces: 10 | 11 | ### The Renderer 12 | 13 | PacisUI comes with its own html renderer for html elements and their attributes. Think of it like [templ](https://templ.guide/) or [gomponents](https://www.gomponents.com/). If you are familiar with the latter, you will find the PacisUI renderer familiar as well. It looks something like this; 14 | 15 | ```go 16 | Div( 17 | ID("my-div") // An attribute 18 | 19 | P(Text("Hello, World!")) // A child with a child 20 | ) 21 | ``` 22 | 23 | You compose your html with go functions with PacisUI. If you are not sure about writing your html with Go functions, give it a try anyway, it might be for you and I believe you will find it very expressive and liberating. 24 | 25 | > Visit the [Syntax & Usage](/docs/syntax-usage) page to dive deep in this subject. 26 | 27 | ### The Components 28 | 29 | The second piece and the focal point of this library is the components. These are, styled, interactive components that you would mainly be using. 30 | 31 | A web app built with PacisUI ideally would use both these components and the builtin html elements along with some custom styling with tailwind. 32 | 33 | > The truth about frontend development is that these libraries are not a 'be all' solution. It will still require a considerable effort to create something beautiful. 34 | 35 | ### Icons 36 | 37 | A *secret* third piece of the puzzle is the icons. PacisUI comes with a prebuilt icon library that is [Lucide](https://lucide.dev/). Icons are an essential part of UI development and I believe an out of the box solution is always needed. 38 | 39 | That being said, you can always bring your own icons in the form of fonts, SVGs or plain images (although I recommend SVGs). I plan to create a better API to interact with *your* custom SVG icons in the future. 40 | 41 | ## How it works 42 | 43 | A simple overview of this library is that it has nothing more than a bunch of functions that create a meaninful UI's. 44 | 45 | The renderer provides some primitive interfaces and other stuff around it (like the components, icons or your own stuff) consume them. 46 | 47 | These primitives are: 48 | 49 | - `renderer.Renderer`: an interface that any renderer implements. If you have worked with [templ](https://templ.guide/) before, the signature will look familiar. 50 | 51 | 52 | ```go 53 | type Renderer interface { 54 | Render(context.Context, io.Writer) error 55 | } 56 | ``` 57 | 58 | - `rendereh.I`: an alias to `renderer.Renderer` for ease of use. 59 | 60 | ```go 61 | type I = Renderer 62 | ``` 63 | 64 | - `renderer.Node`: represents an HTML node that is renderable, this can be anything from an element to a text node. 65 | 66 | ```go 67 | type NodeType int 68 | 69 | const ( 70 | NodeText = NodeType(iota) 71 | NodeElement 72 | NodeFragment 73 | ) 74 | 75 | type Node interface { 76 | Renderer 77 | NodeType() NodeType 78 | } 79 | ``` 80 | 81 | - `renderer.Element`: represents and HTML element but not attributes or texts. 82 | 83 | ```go 84 | type Element interface { 85 | Node 86 | GetTag() string 87 | 88 | GetAttributes() []Attribute 89 | GetAttribute(string) (Attribute, bool) 90 | AddAttribute(Attribute) 91 | RemoveAttribute(string) 92 | 93 | GetNodes() []Node 94 | GetNode(int) (Node, bool) 95 | AddNode(Node) 96 | RemoveNode(int) 97 | 98 | GetElement(int) (Element, bool) 99 | GetElements() []Element 100 | } 101 | ``` 102 | 103 | - `renderer.Attribute`: represents any kind of element attribute. 104 | 105 | ```go 106 | type Attribute interface { 107 | Renderer 108 | GetKey() string 109 | IsEmpty() bool 110 | } 111 | ``` 112 | 113 | By composing these primitives and building up on them, you can create very well designed UI's with great developer experience. If you love Go like I do, building user interfaces with PacisUI should feel like a breath of fresh air. 114 | 115 | > PacisUI is neither complete nor production ready yet but I am working on it. But this documentation site it built with it so it should give you an idea. -------------------------------------------------------------------------------- /staticcheck.conf: -------------------------------------------------------------------------------- 1 | dot_import_whitelist = ["github.com/canpacis/pacis/ui/html", "github.com/canpacis/pacis/ui/components", "github.com/canpacis/pacis/www/app/components"] -------------------------------------------------------------------------------- /ui/components/alert.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | ) 6 | 7 | // Title slot for the alert component 8 | func AlertTitle(props ...h.I) h.Element { 9 | ps := []h.I{ 10 | h.Class("col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight"), 11 | } 12 | ps = append(ps, props...) 13 | 14 | return h.Div(ps...) 15 | } 16 | 17 | // Description slot for the alert component 18 | func AlertDescription(props ...h.I) h.Element { 19 | ps := []h.I{ 20 | h.Class("text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed"), 21 | } 22 | ps = append(ps, props...) 23 | 24 | return h.Div(ps...) 25 | } 26 | 27 | var ( 28 | AlertVariantDefault = &GroupedClass{"alert", "bg-card text-card-foreground", true} 29 | AlertVariantDestructive = &GroupedClass{"alert", "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", false} 30 | ) 31 | 32 | /* 33 | Displays a callout for user attention. 34 | 35 | Usage: 36 | 37 | Alert( 38 | icons.Code(), 39 | AlertTitle(Text("Heads up!")), 40 | AlertDescription(Text("You can us Go tho create great UI's")), 41 | ) 42 | */ 43 | func Alert(props ...h.I) h.Element { 44 | return h.Div( 45 | Join( 46 | props, 47 | AlertVariantDefault, 48 | h.Class("relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current"), 49 | h.Role("alert"), 50 | )..., 51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /ui/components/avatar.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | ) 6 | 7 | func AvatarFallback(props ...h.I) h.Element { 8 | props = Join( 9 | props, 10 | h.Class("bg-muted flex size-full items-center justify-center rounded-full text-sm absolute inset-0 z-10"), 11 | ) 12 | return h.Div(props...) 13 | } 14 | 15 | func AvatarImage(src h.Attribute, props ...h.I) h.Node { 16 | return h.Img( 17 | Join( 18 | props, 19 | D{"url": readattr(src)}, 20 | src, 21 | h.Class("aspect-square size-full relative z-20"), 22 | h.Attr(":class", "error ? 'hidden' : 'block'"), 23 | h.Attr(":src", "url"), 24 | On("error", "error = true"), 25 | )..., 26 | ) 27 | } 28 | 29 | var ( 30 | AvatarSizeDefault = &GroupedClass{"avatar-size", "size-8", true} 31 | AvatarSizeSm = &GroupedClass{"avatar-size", "size-6 text-sm", false} 32 | AvatarSizeLg = &GroupedClass{"avatar-size", "size-12 text-lg", false} 33 | ) 34 | 35 | func Avatar(props ...h.I) h.Element { 36 | return h.Div(Join( 37 | props, 38 | AvatarSizeDefault, 39 | h.Class("flex shrink-0 overflow-hidden rounded-full relative"), 40 | D{"error": false}, 41 | )...) 42 | } 43 | -------------------------------------------------------------------------------- /ui/components/badge.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | ) 6 | 7 | var ( 8 | BadgeVariantDefault = &GroupedClass{ 9 | "badge-variant", 10 | "!border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 11 | true, 12 | } 13 | BadgeVariantSecondary = &GroupedClass{ 14 | "badge-variant", 15 | "!border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | false, 17 | } 18 | BadgeVariantDestructive = &GroupedClass{ 19 | "badge-variant", 20 | "!border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 21 | false, 22 | } 23 | BadgeVariantOutline = &GroupedClass{ 24 | "badge-variant", 25 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 26 | false, 27 | } 28 | ) 29 | 30 | func Badge(props ...h.I) h.Element { 31 | return h.Span( 32 | Join( 33 | props, 34 | BadgeVariantDefault, 35 | h.Class("inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none 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 transition-[color,box-shadow] overflow-hidden"), 36 | )..., 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /ui/components/bench_test.go: -------------------------------------------------------------------------------- 1 | package components_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "testing" 7 | 8 | . "github.com/canpacis/pacis/ui/components" 9 | . "github.com/canpacis/pacis/ui/html" 10 | "github.com/canpacis/pacis/ui/icons" 11 | ) 12 | 13 | type NavLink struct { 14 | Href string 15 | Label Node 16 | } 17 | 18 | type NavSection struct { 19 | Label Node 20 | Items []NavLink 21 | } 22 | 23 | func getNavSections() []NavSection { 24 | return []NavSection{ 25 | { 26 | Label: Text("Getting Started"), 27 | Items: []NavLink{ 28 | {"/docs/introduction", Text("Introduction")}, 29 | // {"/docs/installation", Text("Installation")}, 30 | // {"/docs/quick-start", Text("Quick Start")}, 31 | // {"/docs/syntax-usage", Text("Syntax & Usage")}, 32 | // {"/docs/roadmap", Text("Roadmap")}, 33 | }, 34 | }, 35 | { 36 | Label: Text("Components"), 37 | Items: []NavLink{ 38 | {"/docs/alert", Text("Alert")}, 39 | {"/docs/avatar", Text("Avatar")}, 40 | {"/docs/badge", Text("Badge")}, 41 | {"/docs/button", Text("Button")}, 42 | {"/docs/card", Text("Card")}, 43 | // {"/docs/carousel", Text("Carousel")}, 44 | {"/docs/checkbox", Text("Checkbox")}, 45 | {"/docs/collapsible", Text("Collapsible")}, 46 | {"/docs/dialog", Text("Dialog")}, 47 | {"/docs/dropdown", Text("Dropdown")}, 48 | {"/docs/input", Text("Input")}, 49 | {"/docs/label", Text("Label")}, 50 | // {"/docs/radio", Text("Radio")}, 51 | {"/docs/select", Text("Select")}, 52 | // {"/docs/seperator", Text("Seperator")}, 53 | // {"/docs/sheet", Text("Sheet")}, 54 | // {"/docs/slider", Text("Slider")}, 55 | // {"/docs/switch", Text("Switch")}, 56 | {"/docs/tabs", Text("Tabs")}, 57 | // {"/docs/textarea", Text("Textarea")}, 58 | // {"/docs/toast", Text("Toast")}, 59 | // {"/docs/tooltip", Text("Tooltip")}, 60 | }, 61 | }, 62 | } 63 | } 64 | 65 | func Navigation(sections []NavSection, current *NavLink) I { 66 | iscurr := func(href string) bool { 67 | if current == nil { 68 | return false 69 | } 70 | return current.Href == href 71 | } 72 | 73 | return Map(sections, func(heading NavSection, i int) I { 74 | return Div( 75 | Class("mb-4"), 76 | 77 | H2( 78 | Class("font-semibold text-sm text-muted-foreground mb-2"), 79 | 80 | heading.Label, 81 | ), 82 | 83 | Ul( 84 | Map(heading.Items, func(item NavLink, i int) I { 85 | return Li( 86 | If(!iscurr(item.Href), 87 | A( 88 | Class("rounded-md block text-sm w-full px-2.5 py-1.5 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 cursor-pointer"), 89 | Href(item.Href), 90 | 91 | item.Label, 92 | ), 93 | ), 94 | If(iscurr(item.Href), 95 | Span( 96 | Class("rounded-md block text-sm w-full px-2.5 py-1.5 hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 cursor-pointer"), 97 | 98 | item.Label, 99 | ), 100 | ), 101 | ) 102 | }), 103 | ), 104 | ) 105 | }) 106 | } 107 | 108 | func BenchmarkRender(b *testing.B) { 109 | sections := getNavSections() 110 | links := []NavLink{ 111 | {"/docs/introduction", Text("Docs")}, 112 | {"/docs/components", Text("Components")}, 113 | } 114 | 115 | for b.Loop() { 116 | html := Html( 117 | Class("dark"), 118 | Lang("en"), 119 | 120 | Head( 121 | Meta(Name("title"), Content("Title")), 122 | Meta(Name("description"), Content("Description")), 123 | Meta(Name("keywords"), Content("keywords")), 124 | Meta(Name("robots"), Content("index, follow")), 125 | Meta(HttpEquiv("Content-Type"), Content("text/html; charset=utf-8")), 126 | Meta(HttpEquiv("language"), Content("English")), 127 | Meta(HttpEquiv("author"), Content("canpacis")), 128 | 129 | Meta(Property("og:type"), Content("website")), 130 | Meta(Property("og:url"), Content("https://ui.canpacis.com")), 131 | Meta(Property("og:title"), Content("Title")), 132 | Meta(Property("og:description"), Content("Description")), 133 | Meta(Property("og:image"), Content("/public/banner.webp")), 134 | 135 | Meta(Property("twitter:card"), Content("summary_large_image")), 136 | Meta(Property("twitter:url"), Content("https://ui.canpacis.com")), 137 | Meta(Property("twitter:title"), Content("Title")), 138 | Meta(Property("twitter:description"), Content("Description")), 139 | Meta(Property("twitter:image"), Content("/public/banner.webp")), 140 | 141 | Link(Href("/public/main.css"), Rel("stylesheet")), 142 | Link(Href("/public/favicon.webp"), Rel("icon"), Type("image/png")), 143 | Title(Text("Title")), 144 | ), 145 | Body( 146 | Class("flex flex-col min-h-dvh"), 147 | 148 | Header( 149 | Class("py-3 border-b border-dashed sticky top-0 z-50 w-full bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-[var(--header-height)]"), 150 | 151 | Div( 152 | Class("flex container items-center gap-4 lg:gap-8 h-full"), 153 | 154 | Sheet( 155 | Class("block lg:hidden"), 156 | 157 | SheetTrigger( 158 | Button( 159 | ButtonSizeIcon, 160 | ButtonVariantGhost, 161 | 162 | icons.PanelLeft(), 163 | Span(Class("sr-only"), Text("Toggle Sidebar")), 164 | ), 165 | ), 166 | SheetContent( 167 | Class("overflow-scroll"), 168 | 169 | Navigation(sections, nil), 170 | ), 171 | ), 172 | A( 173 | Class("flex gap-3 items-center"), 174 | Href("/"), 175 | 176 | Img(Src("/public/logo.webp"), Width("24"), Height("24"), Class("w-6"), Alt("logo")), 177 | P(Class("font-semibold inline"), Text("Pacis")), 178 | ), 179 | Ul( 180 | Class("hidden gap-4 lg:flex"), 181 | 182 | Map(links, func(link NavLink, i int) I { 183 | return Li( 184 | Class("text-sm text-muted-foreground"), 185 | 186 | A(Href(link.Href), link.Label), 187 | ) 188 | }), 189 | ), 190 | Div( 191 | Class("flex gap-1 items-center ml-auto"), 192 | 193 | Button( 194 | ButtonSizeIcon, 195 | ButtonVariantGhost, 196 | ToggleColorScheme, 197 | 198 | icons.Sun(), 199 | Span(Class("sr-only"), Text("Toggle Theme")), 200 | ), 201 | ), 202 | ), 203 | ), 204 | Main( 205 | Class("container flex flex-1 items-start gap-4"), 206 | 207 | Aside( 208 | Class("hidden flex-col gap-2 border-r border-dashed py-4 pr-2 sticky overflow-auto top-[var(--header-height)] h-[calc(100dvh-var(--header-height)-var(--footer-height))] min-w-none lg:flex lg:min-w-[240px]"), 209 | 210 | Navigation(sections, nil), 211 | ), 212 | Section( 213 | Class("py-8 flex-1 w-full ml-0 lg:ml-4 xl:ml-8"), 214 | 215 | Button(ButtonSizeDefault), 216 | Button(ButtonSizeSm), 217 | Button(ButtonSizeLg), 218 | Button(ButtonSizeIcon), 219 | Button(ButtonVariantDefault), 220 | Button(ButtonVariantDestructive), 221 | Button(ButtonVariantGhost), 222 | Button(ButtonVariantLink), 223 | Button(ButtonVariantOutline), 224 | Button(ButtonVariantSecondary), 225 | 226 | Button(ButtonSizeSm, ButtonVariantDefault), 227 | Button(ButtonSizeSm, ButtonVariantDestructive), 228 | Button(ButtonSizeSm, ButtonVariantGhost), 229 | Button(ButtonSizeSm, ButtonVariantLink), 230 | Button(ButtonSizeSm, ButtonVariantOutline), 231 | Button(ButtonSizeSm, ButtonVariantSecondary), 232 | Button(ButtonSizeLg, ButtonVariantDefault), 233 | Button(ButtonSizeLg, ButtonVariantDestructive), 234 | Button(ButtonSizeLg, ButtonVariantGhost), 235 | Button(ButtonSizeLg, ButtonVariantLink), 236 | Button(ButtonSizeLg, ButtonVariantOutline), 237 | Button(ButtonSizeLg, ButtonVariantSecondary), 238 | Button(ButtonSizeIcon, ButtonVariantDefault), 239 | Button(ButtonSizeIcon, ButtonVariantDestructive), 240 | Button(ButtonSizeIcon, ButtonVariantGhost), 241 | Button(ButtonSizeIcon, ButtonVariantLink), 242 | Button(ButtonSizeIcon, ButtonVariantOutline), 243 | Button(ButtonSizeIcon, ButtonVariantSecondary), 244 | IfFn(true, func() Renderer { 245 | return Breadcrumb( 246 | Class("mb-4"), 247 | 248 | BreadcrumbItem(Text("Docs")), 249 | BreadcrumbSeperator(), 250 | BreadcrumbItem(Text("Label")), 251 | ) 252 | }), 253 | Div( 254 | Class("flex gap-8 flex-col-reverse xl:flex-row"), 255 | 256 | Div( 257 | Class("text-sm flex-1 h-fit leading-6 relative xl:sticky xl:top-[calc(var(--header-height)+2rem)]"), 258 | 259 | P(Class("font-semibold text-primary"), Text("On This Page")), 260 | ), 261 | ), 262 | Div( 263 | Class("flex gap-8 mb-[var(--footer-height)]"), 264 | 265 | Div( 266 | Class("flex mt-12 flex-3 w-full xl:w-fit"), 267 | 268 | IfFn(true, func() Renderer { 269 | return Button(Text("true")) 270 | }), 271 | IfFn(false, func() Renderer { 272 | return Button(Text("false")) 273 | }), 274 | ), 275 | Div(Class("flex-1 hidden xl:block")), 276 | ), 277 | ), 278 | ), 279 | Footer( 280 | Class("border-t border-dashed py-2 text-center h-[var(--footer-height)] fixed bottom-0 w-dvw bg-background"), 281 | 282 | P(Class("text-sm text-muted-foreground"), Text("Built by "), A(Href("https://canpacis.com"), Class("hover:underline"), Text("canpacis"))), 283 | ), 284 | ), 285 | ) 286 | 287 | var buf = new(bytes.Buffer) 288 | html.Render(context.Background(), buf) 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /ui/components/breadcrumb.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | "github.com/canpacis/pacis/ui/icons" 6 | ) 7 | 8 | func Breadcrumb(props ...h.I) h.Element { 9 | return h.Div( 10 | h.Aria("label", "breadcrumb"), 11 | h.Ol( 12 | Join( 13 | props, 14 | h.Class("flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5"), 15 | )..., 16 | ), 17 | ) 18 | } 19 | 20 | func BreadcrumbItem(props ...h.I) h.Element { 21 | return h.Li( 22 | Join( 23 | props, 24 | h.Class("inline-flex items-center gap-1.5"), 25 | )..., 26 | ) 27 | } 28 | 29 | func BreadcrumbSeperator(props ...h.I) h.Element { 30 | return h.Li( 31 | Join( 32 | props, 33 | h.Role("presentation"), 34 | h.Aria("hidden", "true"), 35 | 36 | icons.ChevronRight(h.Class("size-3.5")), 37 | )..., 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /ui/components/button.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | ) 6 | 7 | var ( 8 | ButtonSizeDefault = &GroupedClass{"button-size", "h-9 px-4 py-2 has-[>svg]:px-3", true} 9 | ButtonSizeSm = &GroupedClass{"button-size", "h-8 rounded-md text-xs gap-1.5 px-3 has-[>svg]:px-2.5", false} 10 | ButtonSizeLg = &GroupedClass{"button-size", "h-10 rounded-md px-6 has-[>svg]:px-4", false} 11 | ButtonSizeIcon = &GroupedClass{"button-size", "size-9", false} 12 | ) 13 | 14 | var ( 15 | ButtonVariantDefault = &GroupedClass{ 16 | "button-variant", 17 | "shadow-xs bg-primary text-primary-foreground hover:bg-primary/90", 18 | true, 19 | } 20 | ButtonVariantDestructive = &GroupedClass{ 21 | "button-variant", 22 | "text-white shadow-xs bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40", 23 | false, 24 | } 25 | ButtonVariantOutline = &GroupedClass{ 26 | "button-variant", 27 | "border shadow-xs bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 28 | false, 29 | } 30 | ButtonVariantSecondary = &GroupedClass{ 31 | "button-variant", 32 | "shadow-xs bg-secondary text-secondary-foreground hover:bg-secondary/80", 33 | false, 34 | } 35 | ButtonVariantGhost = &GroupedClass{ 36 | "button-variant", 37 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 38 | false, 39 | } 40 | ButtonVariantLink = &GroupedClass{ 41 | "button-variant", 42 | "text-primary underline-offset-4 hover:underline", 43 | false, 44 | } 45 | ) 46 | 47 | func Button(props ...h.I) h.Element { 48 | props = Join( 49 | props, 50 | ButtonSizeDefault, 51 | ButtonVariantDefault, 52 | h.Class("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none 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"), 53 | ) 54 | el := h.Btn(props...) 55 | 56 | attr, ok := el.GetAttribute("replace") 57 | if ok { 58 | el := attr.(*Replacer).element(props...) 59 | el.RemoveAttribute("replace") 60 | return el 61 | } 62 | return el 63 | } 64 | -------------------------------------------------------------------------------- /ui/components/calendar.go: -------------------------------------------------------------------------------- 1 | package components 2 | -------------------------------------------------------------------------------- /ui/components/card.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import h "github.com/canpacis/pacis/ui/html" 4 | 5 | func CardHeader(props ...h.I) h.Element { 6 | ps := []h.I{ 7 | h.Class("@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"), 8 | } 9 | ps = append(ps, props...) 10 | 11 | return h.Div(ps...) 12 | } 13 | 14 | func CardTitle(props ...h.I) h.Element { 15 | ps := []h.I{ 16 | h.Class("leading-none font-semibold"), 17 | } 18 | ps = append(ps, props...) 19 | 20 | return h.Div(ps...) 21 | } 22 | 23 | func CardDescription(props ...h.I) h.Element { 24 | ps := []h.I{ 25 | h.Class("ext-muted-foreground text-sm"), 26 | } 27 | ps = append(ps, props...) 28 | 29 | return h.Div(ps...) 30 | } 31 | 32 | func CardAction(props ...h.I) h.Element { 33 | ps := []h.I{ 34 | h.Class("col-start-2 row-span-2 row-start-1 self-start justify-self-end"), 35 | } 36 | ps = append(ps, props...) 37 | 38 | return h.Div(ps...) 39 | } 40 | 41 | func CardContent(props ...h.I) h.Element { 42 | ps := []h.I{ 43 | h.Class("px-6"), 44 | } 45 | ps = append(ps, props...) 46 | 47 | return h.Div(ps...) 48 | } 49 | 50 | func CardFooter(props ...h.I) h.Element { 51 | ps := []h.I{ 52 | h.Class("flex items-center px-6 [.border-t]:pt-6"), 53 | } 54 | ps = append(ps, props...) 55 | 56 | return h.Div(ps...) 57 | } 58 | 59 | func Card(props ...h.I) h.Element { 60 | ps := []h.I{ 61 | h.Class("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm"), 62 | } 63 | ps = append(ps, props...) 64 | 65 | return h.Div(ps...) 66 | } 67 | -------------------------------------------------------------------------------- /ui/components/carousel.go: -------------------------------------------------------------------------------- 1 | package components 2 | -------------------------------------------------------------------------------- /ui/components/checkbox.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | "github.com/canpacis/pacis/ui/icons" 6 | ) 7 | 8 | /* 9 | A control that allows the user to toggle between checked and not checked. 10 | 11 | Usage: 12 | 13 | Checkbox() 14 | // ... 15 | Checkbox(Text("Label")) 16 | // ... 17 | Checkbox(On("changed", "console.log($event)")) 18 | */ 19 | func Checkbox(props ...h.I) h.Element { 20 | el := h.S(props...) 21 | _, checked := el.GetAttribute("checked") 22 | id := getid(el) 23 | 24 | // TODO: route the checked property to input inside 25 | return h.Lbl( 26 | Join( 27 | props, 28 | h.Class("text-sm gap-2 items-center inline-flex w-fit-content cursor-pointer"), h.HtmlFor(id), 29 | h.Span( 30 | h.Class("peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 aspect-square shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50"), 31 | X("data", fn("checkbox", checked, id)), 32 | h.Attr(":data-state", "checked ? 'checked' : 'unchecked'"), 33 | 34 | h.Span( 35 | h.Class("items-center justify-center text-current transition-none"), 36 | h.Attr(":class", "!checked ? 'hidden' : 'flex'"), 37 | icons.Check(h.Class("size-3.5")), 38 | ), 39 | 40 | h.Inpt( 41 | h.ID(id), 42 | h.Type("checkbox"), 43 | h.Class("sr-only"), 44 | X("bind:checked", "checked"), 45 | ToggleCheckboxOn("change"), 46 | ), 47 | ), 48 | )...) 49 | } 50 | 51 | // Returns an attribute that toggles the related checkbox upon given event 52 | func ToggleCheckboxOn(event string) h.Attribute { 53 | return On(event, "toggleCheckbox()") 54 | } 55 | 56 | // An attribute that toggles the related checkbox on click 57 | var ToggleCheckbox = ToggleCheckboxOn("click") 58 | -------------------------------------------------------------------------------- /ui/components/code.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "io" 9 | 10 | "github.com/alecthomas/chroma/v2" 11 | "github.com/alecthomas/chroma/v2/lexers" 12 | "github.com/alecthomas/chroma/v2/styles" 13 | h "github.com/canpacis/pacis/ui/html" 14 | "github.com/canpacis/pacis/ui/icons" 15 | ) 16 | 17 | type CodeHighlighter struct { 18 | Language string 19 | Source string 20 | Props []h.I 21 | } 22 | 23 | func gettokclass(typ chroma.TokenType) string { 24 | switch typ { 25 | case chroma.PreWrapper: 26 | return "whitespace-pre-wrap" 27 | 28 | case chroma.Line: 29 | return "flex" 30 | 31 | case chroma.LineTable: 32 | return "border-spacing-0 p-0 m-0 border-0" 33 | case chroma.LineTableTD: 34 | return "align-top p-0 m-0 border-0" 35 | 36 | case chroma.LineLink: 37 | return "outline-0 decoration-0 text-inherit" 38 | 39 | case chroma.LineNumbers, 40 | chroma.LineNumbersTable: 41 | return "whitespace-pre-wrap select-none mr-4 py-2 text-neutral-400 dark:text-neutral-600" 42 | 43 | case chroma.LineHighlight: 44 | return "bg-sky-600/40" 45 | 46 | case chroma.Error: 47 | return "text-red-500 dark:text-red-400 bg-red-950 dark:bg-red-800" 48 | 49 | case chroma.Keyword, 50 | chroma.KeywordConstant, 51 | chroma.KeywordDeclaration, 52 | chroma.KeywordPseudo, 53 | chroma.KeywordReserved, 54 | chroma.KeywordType: 55 | return "text-rose-600 dark:text-rose-400" 56 | 57 | case chroma.KeywordNamespace: 58 | return "text-indigo-600 dark:text-indigo-400" 59 | 60 | case chroma.NameAttribute, 61 | chroma.NameClass, 62 | chroma.NameConstant, 63 | chroma.NameDecorator, 64 | chroma.NameEntity, 65 | chroma.NameException, 66 | chroma.NameFunction, 67 | chroma.NameFunctionMagic, 68 | chroma.NameKeyword, 69 | chroma.NameOperator: 70 | return "text-blue-600 dark:text-blue-400" 71 | 72 | case chroma.NameOther: 73 | return "text-muted-foreground" 74 | 75 | case chroma.Name, 76 | chroma.NameBuiltin, 77 | chroma.NameBuiltinPseudo, 78 | chroma.NameLabel, 79 | chroma.NameNamespace, 80 | chroma.NamePseudo, 81 | chroma.NameProperty, 82 | chroma.NameTag, 83 | chroma.NameVariable, 84 | chroma.NameVariableAnonymous, 85 | chroma.NameVariableClass, 86 | chroma.NameVariableGlobal, 87 | chroma.NameVariableInstance, 88 | chroma.NameVariableMagic: 89 | return "text-neutral-900 dark:text-neutral-100" 90 | 91 | case chroma.LiteralString, 92 | chroma.LiteralStringAffix, 93 | chroma.LiteralStringAtom, 94 | chroma.LiteralStringBacktick, 95 | chroma.LiteralStringBoolean, 96 | chroma.LiteralStringChar, 97 | chroma.LiteralStringDelimiter, 98 | chroma.LiteralStringDoc, 99 | chroma.LiteralStringDouble, 100 | chroma.LiteralStringEscape, 101 | chroma.LiteralStringHeredoc, 102 | chroma.LiteralStringInterpol, 103 | chroma.LiteralStringName, 104 | chroma.LiteralStringOther, 105 | chroma.LiteralStringRegex, 106 | chroma.LiteralStringSingle, 107 | chroma.LiteralStringSymbol: 108 | return "text-emerald-700 dark:text-emerald-300" 109 | 110 | case chroma.Literal, 111 | chroma.LiteralNumber, 112 | chroma.LiteralDate, 113 | chroma.LiteralOther, 114 | chroma.LiteralNumberBin, 115 | chroma.LiteralNumberFloat, 116 | chroma.LiteralNumberHex, 117 | chroma.LiteralNumberInteger, 118 | chroma.LiteralNumberIntegerLong, 119 | chroma.LiteralNumberOct, 120 | chroma.LiteralNumberByte: 121 | return "text-orange-600 dark:text-orange-400" 122 | 123 | case chroma.Operator, chroma.OperatorWord: 124 | return "text-neutral-200 dark:text-neutral-400" 125 | 126 | case chroma.Punctuation: 127 | return "text-muted-foreground" 128 | 129 | case chroma.Comment, 130 | chroma.CommentHashbang, 131 | chroma.CommentMultiline, 132 | chroma.CommentSingle, 133 | chroma.CommentSpecial, 134 | chroma.CommentPreproc, 135 | chroma.CommentPreprocFile: 136 | return "text-neutral-400 dark:text-neutral-600" 137 | 138 | case chroma.GenericEmph: 139 | return "italic" 140 | case chroma.GenericStrong: 141 | return "font-bold" 142 | case chroma.GenericUnderline: 143 | return "underline" 144 | default: 145 | return "" 146 | } 147 | } 148 | 149 | var htmlformatter = chroma.FormatterFunc(func(w io.Writer, style *chroma.Style, iterator chroma.Iterator) error { 150 | for token := range iterator.Stdlib() { 151 | class := gettokclass(token.Type) 152 | el := h.Span( 153 | h.If(len(class) > 0, h.Class(class)), 154 | 155 | h.Text(token.Value), 156 | ) 157 | ctx := context.Background() 158 | ctxwer, ok := w.(*ctxwriter) 159 | if ok { 160 | ctx = ctxwer.ctx 161 | } 162 | 163 | err := el.Render(ctx, w) 164 | if err != nil { 165 | return err 166 | } 167 | } 168 | 169 | return nil 170 | }) 171 | 172 | type ctxwriter struct { 173 | io.Writer 174 | ctx context.Context 175 | } 176 | 177 | var codecache = map[string][]byte{} 178 | 179 | func (c *CodeHighlighter) Render(ctx context.Context, w io.Writer) error { 180 | hash := sha256.New() 181 | hash.Write([]byte(c.Source)) 182 | sum := hex.EncodeToString(hash.Sum(nil)) 183 | 184 | cache, ok := codecache[sum] 185 | if ok { 186 | _, err := w.Write(cache) 187 | return err 188 | } 189 | 190 | lexer := lexers.Get(c.Language) 191 | if lexer == nil { 192 | lexer = lexers.Fallback 193 | } 194 | lexer = chroma.Coalesce(lexer) 195 | iterator, err := lexer.Tokenise(nil, c.Source) 196 | if err != nil { 197 | return err 198 | } 199 | 200 | var buf = new(bytes.Buffer) 201 | if err := htmlformatter.Format(&ctxwriter{Writer: buf, ctx: ctx}, styles.Fallback, iterator); err != nil { 202 | return err 203 | } 204 | 205 | codecache[sum] = buf.Bytes() 206 | _, err = io.Copy(w, buf) 207 | return err 208 | } 209 | 210 | func (c *CodeHighlighter) NodeType() h.NodeType { 211 | return h.NodeElement 212 | } 213 | 214 | // TODO: Add a prop to include the copy button 215 | func Code(src, lang string, props ...h.I) h.Node { 216 | return h.Div( 217 | Join( 218 | props, 219 | h.Class("relative overflow-x-auto"), 220 | 221 | Button( 222 | ButtonSizeIcon, 223 | ButtonVariantGhost, 224 | h.Class("!size-7 rounded-sm absolute top-2 right-2 md:top-3 md:right-3"), 225 | On("click", fn("$clipboard", src)), 226 | 227 | icons.Clipboard(h.Class("size-3")), 228 | h.Span(h.Class("sr-only"), h.Text("Copy to Clipboard")), 229 | ), 230 | h.Pre( 231 | h.Class("p-3 pr-10 md:p-6 md:pr-12 overflow-auto bg-accent/50 dark:bg-accent/20 rounded-lg text-muted-foreground selection:bg-sky-600/20 whitespace-pre-wrap"), 232 | 233 | h.Cde( 234 | &CodeHighlighter{Language: lang, Source: src, Props: props}, 235 | ), 236 | ), 237 | )..., 238 | ) 239 | } 240 | -------------------------------------------------------------------------------- /ui/components/collapsible.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | ) 6 | 7 | /* 8 | An interactive component which expands/collapses a panel. 9 | 10 | Usage: 11 | 12 | Collapsible( 13 | CollapsibleTrigger( 14 | Button(Text("Trigger")) // <- Pass a button element for accessiblity 15 | ), 16 | CollapsibleContent( 17 | P(Text("Content")) 18 | ), 19 | ) 20 | */ 21 | func Collapsible(props ...h.I) h.Element { 22 | el := h.Div(props...) 23 | 24 | open, hasopen := el.GetAttribute("open") 25 | _, ok := open.(ComponentAttribute) 26 | id := getid(el) 27 | 28 | el.AddAttribute(X("data", fn("collapsible", hasopen && ok, id))) 29 | return el 30 | } 31 | 32 | // A trigger element for collapsible component 33 | func CollapsibleTrigger(trigger h.Element) h.Element { 34 | trigger.AddAttribute(ToggleCollapsible) 35 | return trigger 36 | } 37 | 38 | // Content slot for the collapsible component 39 | func CollapsibleContent(content h.Element) h.Element { 40 | content.AddAttribute(X("show", "open")) 41 | content.AddAttribute(X("collapse")) 42 | return content 43 | } 44 | 45 | /* 46 | Returns an attribute that toggles the related collapsible upon given event 47 | 48 | Usage: 49 | 50 | Collapsible( 51 | CollapsibleTrigger( ... ), 52 | CollapsibleContent( 53 | Button( // <- Another button for toggling 54 | ToggleCollapsibleOn("mouseover") // <- will toggle the collapsible when hovered 55 | 56 | Text("Toggle outside") 57 | ), 58 | ), 59 | ) 60 | */ 61 | func ToggleCollapsibleOn(event string) h.Attribute { 62 | return On(event, "toggleCollapsible()") 63 | } 64 | 65 | /* 66 | An attribute that toggles the related collapsible on click 67 | 68 | Usage: 69 | 70 | Collapsible( 71 | CollapsibleTrigger( ... ), 72 | CollapsibleContent( 73 | Button( // <- Another button for toggling 74 | ToggleCollapsible 75 | 76 | Text("Toggle outside") 77 | ), 78 | ), 79 | ) 80 | */ 81 | var ToggleCollapsible = ToggleCollapsibleOn("click") 82 | -------------------------------------------------------------------------------- /ui/components/components.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/rand" 7 | "embed" 8 | "encoding/hex" 9 | "encoding/json" 10 | "fmt" 11 | "io" 12 | "log" 13 | "strings" 14 | 15 | h "github.com/canpacis/pacis/ui/html" 16 | ) 17 | 18 | func randid() string { 19 | buf := make([]byte, 8) 20 | rand.Read(buf) 21 | return "pacis-" + hex.EncodeToString(buf) 22 | } 23 | 24 | func readattr(attr h.Attribute) string { 25 | var buf bytes.Buffer 26 | attr.Render(context.Background(), &buf) 27 | return buf.String() 28 | } 29 | 30 | func getid(el h.Element) string { 31 | idattr, hasid := el.GetAttribute("id") 32 | if !hasid { 33 | return randid() 34 | } else { 35 | return readattr(idattr) 36 | } 37 | } 38 | 39 | func fn(name string, args ...any) string { 40 | list := []string{} 41 | for _, arg := range args { 42 | if arg == nil { 43 | list = append(list, "null") 44 | continue 45 | } 46 | switch arg := arg.(type) { 47 | case string: 48 | if strings.Contains(arg, "\n") { 49 | list = append(list, "`"+arg+"`") 50 | } else { 51 | list = append(list, "'"+arg+"'") 52 | } 53 | case int, uint, int8, int16, int32, int64, uint8, uint16, uint32, uint64: 54 | list = append(list, fmt.Sprintf("%d", arg)) 55 | case float32, float64: 56 | list = append(list, fmt.Sprintf("%f", arg)) 57 | case bool: 58 | list = append(list, fmt.Sprintf("%t", arg)) 59 | default: 60 | log.Fatalf("cannot serialize function argument type %T", arg) 61 | } 62 | } 63 | return fmt.Sprintf("%s(%s)", name, strings.Join(list, ", ")) 64 | } 65 | 66 | type GroupedClass struct { 67 | group string 68 | class h.Class 69 | isdefault bool 70 | } 71 | 72 | func (GroupedClass) Render(context.Context, io.Writer) error { 73 | return nil 74 | } 75 | 76 | type groupedclasses []*GroupedClass 77 | 78 | func (list groupedclasses) Render(ctx context.Context, w io.Writer) error { 79 | selected := list.Candidate() 80 | if selected == nil { 81 | return nil 82 | } 83 | return selected.class.Render(ctx, w) 84 | } 85 | 86 | func (list groupedclasses) Candidate() *GroupedClass { 87 | if len(list) == 0 { 88 | return nil 89 | } 90 | var def *GroupedClass 91 | var selected *GroupedClass 92 | 93 | for _, item := range list { 94 | if item.isdefault { 95 | def = item 96 | } else { 97 | selected = item 98 | } 99 | } 100 | if selected == nil { 101 | selected = def 102 | } 103 | return selected 104 | } 105 | 106 | func (groupedclasses) GetKey() string { 107 | return "class" 108 | } 109 | 110 | func (a groupedclasses) IsEmpty() bool { 111 | return false 112 | } 113 | 114 | func (groupedclasses) Dedupe() {} 115 | 116 | /* 117 | Joins a prop list with rest. Puts the props at the end for correct attribute deduplication. 118 | 119 | Usage: 120 | 121 | func Component(props ...I) Element { 122 | return Div( 123 | Join( 124 | props, 125 | Class( ... ) 126 | )... 127 | ) 128 | } 129 | */ 130 | func Join(props []h.I, rest ...h.I) []h.I { 131 | source := []h.I{} 132 | source = append(source, rest...) 133 | source = append(source, props...) 134 | 135 | result := []h.I{} 136 | 137 | groups := map[string]groupedclasses{} 138 | 139 | for _, prop := range source { 140 | grouped, ok := prop.(*GroupedClass) 141 | if ok { 142 | groups[grouped.group] = append(groups[grouped.group], grouped) 143 | } else { 144 | result = append(result, prop) 145 | } 146 | } 147 | for _, group := range groups { 148 | result = append(result, &group) 149 | } 150 | 151 | return result 152 | } 153 | 154 | /* 155 | Provide x-data attributes to your elements. 156 | 157 | Usage: 158 | 159 | Div( 160 | D{"open": false} 161 | ) //
162 | */ 163 | type D map[string]any 164 | 165 | func (d D) Render(ctx context.Context, w io.Writer) error { 166 | enc, err := json.Marshal(d) 167 | if err != nil { 168 | return err 169 | } 170 | 171 | _, err = w.Write([]byte(strings.ReplaceAll(string(enc), "\"", "'"))) 172 | return err 173 | } 174 | 175 | func (D) GetKey() string { 176 | return "x-data" 177 | } 178 | 179 | func (d D) IsEmpty() bool { 180 | return d == nil 181 | } 182 | 183 | /* 184 | Provide arbitrary x attributes to your elements. 185 | 186 | Usage: 187 | 188 | Div(X("show", "false")) // 189 | */ 190 | func X(key string, value ...any) h.Attribute { 191 | return h.Attr(fmt.Sprintf("x-%s", key), value...) 192 | } 193 | 194 | func Textx(value string) h.Attribute { 195 | return h.Attr("x-text", value) 196 | } 197 | 198 | /* 199 | Provide arbitrary event attributes to your elements. 200 | 201 | Usage: 202 | 203 | Button(On("click", "console.log('clicked')")) // 204 | */ 205 | func On(event string, handler string) h.Attribute { 206 | return h.Attr(fmt.Sprintf("x-on:%s", event), handler) 207 | } 208 | 209 | // Toggles color scheme upon a click event 210 | var ToggleColorScheme = On("click", "$store.colorScheme.toggle()") 211 | 212 | /* 213 | An error handler element that you can use with error boundaries 214 | 215 | Usage: 216 | 217 | Try( 218 | MightError(), 219 | ErrorText, // provides a simple error message on both frontend and the terminal 220 | ) 221 | */ 222 | func ErrorText(err error) h.Node { 223 | log.Println(err.Error()) 224 | 225 | return h.Div( 226 | h.Class("fixed inset-0 flex justify-center items-center z-80"), 227 | 228 | h.Div(h.Class("bg-neutral-800/60 absolute inset-0")), 229 | h.Div( 230 | h.Class("bg-neutral-800 text-red-600 rounded-sm p-4 relative z-90"), 231 | 232 | h.Textf("Failed to render: %s", err.Error()), 233 | ), 234 | ) 235 | } 236 | 237 | func JSON(data any, props ...h.I) h.Element { 238 | raw, err := json.Marshal(data) 239 | if err != nil { 240 | panic(err) 241 | } 242 | return h.Script(Join(props, h.Type("application/json"), h.RawUnsafe(raw))...) 243 | } 244 | 245 | func Store(key string, data any) h.Element { 246 | return JSON(data, h.Data("store-key", key)) 247 | } 248 | 249 | //go:embed dist 250 | var dist embed.FS 251 | 252 | func AppScript() []byte { 253 | js, err := dist.ReadFile("dist/main.js") 254 | if err != nil { 255 | log.Fatalln(err) 256 | } 257 | return js 258 | } 259 | 260 | func AppStyle() []byte { 261 | css, err := dist.ReadFile("dist/main.css") 262 | if err != nil { 263 | log.Fatalln(err) 264 | } 265 | return css 266 | } 267 | 268 | /* 269 | Provides vertical positioning for anchored elements like tooltips and dropdown menus. 270 | See components.AnchorPosition type or components.Anchor function for usage details. 271 | 272 | Available options are; 273 | - components.VTop: Position on top of an element 274 | - components.VBottom: Position on the bottom of an element 275 | */ 276 | type VPos int 277 | 278 | const ( 279 | VTop = VPos(iota) 280 | VBottom 281 | ) 282 | 283 | /* 284 | Provides horizontal positioning for anchored elements like tooltips and dropdown menus. 285 | See components.AnchorPosition type or components.Anchor function for usage details. 286 | 287 | Available options are; 288 | - components.HStart: Position at the start of an element 289 | - components.HCenter: Position at the center of an element 290 | - components.HEnd: Position at the end of an element 291 | */ 292 | type HPos int 293 | 294 | const ( 295 | HStart = HPos(iota) 296 | HCenter 297 | HEnd 298 | ) 299 | 300 | /* 301 | Provides anchor positioning attributes to given element 302 | 303 | Usage: 304 | 305 | Dropdown( 306 | DropdownTrigger( ... ) 307 | DropdownContent( 308 | // Pass this to content elements 309 | Anchor(VBottom, HCenter, 12) 310 | // Positions content at bottom center of the trigger, offsetted 12 pixels 311 | ) 312 | ) 313 | */ 314 | type AnchorPosition struct { 315 | vpos VPos 316 | hpos HPos 317 | offset int 318 | } 319 | 320 | func (a AnchorPosition) Render(ctx context.Context, w io.Writer) error { 321 | _, err := w.Write([]byte("$refs.anchor")) 322 | return err 323 | } 324 | 325 | func (a AnchorPosition) GetKey() string { 326 | key := "x-anchor" 327 | 328 | switch a.vpos { 329 | case VTop: 330 | key += ".top" 331 | case VBottom: 332 | key += ".bottom" 333 | default: 334 | log.Fatalln("invalid vertical position for anchor") 335 | } 336 | 337 | switch a.hpos { 338 | case HCenter: 339 | case HStart: 340 | key += "-start" 341 | case HEnd: 342 | key += "-end" 343 | default: 344 | log.Fatalln("invalid horizontal position for anchor") 345 | } 346 | 347 | key += fmt.Sprintf(".offset.%d", a.offset) 348 | 349 | return key 350 | } 351 | 352 | func (a AnchorPosition) IsEmpty() bool { 353 | return false 354 | } 355 | 356 | /* 357 | Provides anchor positioning attributes to given element 358 | 359 | Usage: 360 | 361 | Dropdown( 362 | DropdownTrigger( ... ) 363 | DropdownContent( 364 | // Pass this to content elements 365 | Anchor(VBottom, HCenter, 12) 366 | // Positions content at bottom center of the trigger, offsetted 12 pixels 367 | ) 368 | ) 369 | */ 370 | func Anchor(v VPos, h HPos, offset int) AnchorPosition { 371 | return AnchorPosition{vpos: v, hpos: h, offset: offset} 372 | } 373 | 374 | // Implements Deduper interface to deduplicate attribute 375 | // and use the last provided value as the final attribte 376 | func (a AnchorPosition) Dedupe() {} 377 | 378 | type Replacer struct { 379 | element func(items ...h.I) h.Element 380 | } 381 | 382 | func (*Replacer) Render(context.Context, io.Writer) error { 383 | return nil 384 | } 385 | 386 | func (*Replacer) GetKey() string { 387 | return "replace" 388 | } 389 | 390 | func (*Replacer) IsEmpty() bool { 391 | return true 392 | } 393 | 394 | func Replace(element func(items ...h.I) h.Element) *Replacer { 395 | return &Replacer{element: element} 396 | } 397 | 398 | type Orientation int 399 | 400 | const ( 401 | OHorizontal = Orientation(iota) 402 | OVertical 403 | ) 404 | 405 | func (o Orientation) String() string { 406 | switch o { 407 | case OHorizontal: 408 | return "horizontal" 409 | case OVertical: 410 | return "vertical" 411 | default: 412 | log.Fatalln("invalid orientation value") 413 | return "" 414 | } 415 | } 416 | 417 | type ComponentAttribute int 418 | 419 | func (ComponentAttribute) Render(context.Context, io.Writer) error { 420 | return nil 421 | } 422 | 423 | func (a ComponentAttribute) GetKey() string { 424 | switch a { 425 | case Clearable: 426 | return "clearable" 427 | case Open: 428 | return "open" 429 | default: 430 | return "invalid-input-attribute" 431 | } 432 | } 433 | 434 | func (ComponentAttribute) IsEmpty() bool { 435 | return true 436 | } 437 | 438 | const ( 439 | Clearable = ComponentAttribute(iota) 440 | Open 441 | ) 442 | 443 | func Changed(handler string) h.Attribute { 444 | return On("changed", handler) 445 | } 446 | 447 | func Opened(handler string) h.Attribute { 448 | return On("opened", handler) 449 | } 450 | 451 | func Closed(handler string) h.Attribute { 452 | return On("closed", handler) 453 | } 454 | 455 | func Dismissed(handler string) h.Attribute { 456 | return On("dismissed", handler) 457 | } 458 | -------------------------------------------------------------------------------- /ui/components/dialog.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | "github.com/canpacis/pacis/ui/icons" 6 | ) 7 | 8 | /* 9 | A window overlaid on either the primary window or another dialog window, rendering the content underneath inert. 10 | 11 | Usage: 12 | 13 | Dialog( 14 | DialogTrigger( 15 | Button(Text("Open Dialog")), 16 | ), 17 | DialogContent( 18 | DialogHeader( 19 | DialogTitle(Text("Are you absolutely sure?")), 20 | DialogDescription(Text("This action cannot be undone. This will permanently delete your account and remove your data from our servers.")), 21 | ), 22 | ), 23 | ) 24 | */ 25 | func Dialog(props ...h.I) h.Element { 26 | props = Join( 27 | props, 28 | X("data", "dialog"), 29 | X("trap.noscroll", "open"), 30 | DismissDialogOn("keydown.esc.window"), 31 | ) 32 | 33 | return h.Div(props...) 34 | } 35 | 36 | // The trigger slot for the dialog component 37 | func DialogTrigger(trigger h.Element) h.Element { 38 | trigger.AddAttribute(OpenDialog) 39 | return trigger 40 | } 41 | 42 | // The content slot for the dialog component 43 | func DialogContent(props ...h.I) h.Node { 44 | ps := []h.I{ 45 | h.Class("fixed left-[50%] top-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 rounded-lg"), 46 | h.Data(":state", "open ? 'open' : 'closed'"), 47 | DismissDialogOn("click.outside"), 48 | X("show", "open"), 49 | X("transition:enteh.scale.96"), 50 | 51 | h.Div( 52 | h.Class("absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"), 53 | 54 | Button( 55 | ButtonSizeIcon, 56 | ButtonVariantGhost, 57 | DismissDialog, 58 | h.Class("h-6 w-6 rounded-sm"), 59 | 60 | icons.X(h.Class("h-4 w-4")), 61 | ), 62 | ), 63 | } 64 | ps = append(ps, props...) 65 | 66 | return h.Frag( 67 | // Overlay 68 | h.Div( 69 | h.Class("fixed inset-0 z-50 bg-black/80 w-dvw h-dvh data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"), 70 | h.Data(":state", "open ? 'open' : 'closed'"), 71 | X("show", "open"), 72 | ), 73 | h.Div(ps...), 74 | ) 75 | } 76 | 77 | // The header slot for the dialog component 78 | func DialogHeader(props ...h.I) h.Element { 79 | props = Join(props, h.Class("flex flex-col space-y-1.5 text-center sm:text-left")) 80 | return h.Div(props...) 81 | } 82 | 83 | // The footer slot for the dialog component 84 | func DialogFooter(props ...h.I) h.Element { 85 | props = Join(props, h.Class("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2")) 86 | return h.Div(props...) 87 | } 88 | 89 | // The title slot for the dialog component 90 | func DialogTitle(props ...h.I) h.Element { 91 | props = Join(props, h.Class("text-lg font-semibold leading-none tracking-tight")) 92 | return h.Span(props...) 93 | } 94 | 95 | // The description slot for the dialog component 96 | func DialogDescription(props ...h.I) h.Element { 97 | props = Join(props, h.Class("text-sm text-muted-foreground")) 98 | return h.Span(props...) 99 | } 100 | 101 | func OpenDialogOn(event string) h.Attribute { 102 | return On(event, "openDialog()") 103 | } 104 | 105 | var OpenDialog = OpenDialogOn("click") 106 | 107 | func CloseDialogOn(event, value string) h.Attribute { 108 | return On(event, fn("closeDialog", value)) 109 | } 110 | 111 | func CloseDialog(value string) h.Attribute { 112 | return CloseDialogOn("click", value) 113 | } 114 | 115 | func DismissDialogOn(event string) h.Attribute { 116 | return On(event, "dismissDialog()") 117 | } 118 | 119 | var DismissDialog = DismissDialogOn("click") 120 | -------------------------------------------------------------------------------- /ui/components/dropdown.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | ) 6 | 7 | func Dropdown(props ...h.I) h.Element { 8 | el := h.Div( 9 | Join( 10 | props, 11 | h.Class("relative"), 12 | DismissDropdownOn("keydown.esc.window"), 13 | )..., 14 | ) 15 | open, hasopen := el.GetAttribute("open") 16 | _, ok := open.(ComponentAttribute) 17 | 18 | id := getid(el) 19 | el.AddAttribute(X("data", fn("dropdown", hasopen && ok, id))) 20 | 21 | return el 22 | } 23 | 24 | func DropdownTrigger(trigger h.Element) h.Element { 25 | trigger.AddAttribute(OpenDropdown) 26 | trigger.AddAttribute(X("ref", "anchor")) 27 | return trigger 28 | } 29 | 30 | func DropdownContent(props ...h.I) h.Element { 31 | props = Join( 32 | props, 33 | h.Class("min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50"), 34 | X("cloak"), 35 | X("show", "open || usedKeyboard"), 36 | X("transition"), 37 | X("trap.inert.noscroll.noautofocus", "open"), 38 | Anchor(VBottom, HStart, 8), 39 | DismissDropdownOn("click.outside"), 40 | On("keydown.down.prevent", "$focus.wrap().next();"), 41 | On("keydown.up.prevent", "$focus.wrap().previous();"), 42 | ) 43 | return h.Template( 44 | X("teleport", "body"), 45 | h.Div(props...), 46 | ) 47 | } 48 | 49 | func DropdownItem(value h.Attribute, props ...h.I) h.Node { 50 | props = Join( 51 | props, 52 | h.Class("relative flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0 hover:bg-accent"), 53 | ) 54 | 55 | if value.GetKey() != "href" { 56 | props = append(props, CloseDropdown(readattr(value))) 57 | } else { 58 | props = append(props, value) 59 | } 60 | 61 | el := h.Btn(props...) 62 | 63 | attr, ok := el.GetAttribute("replace") 64 | if ok { 65 | el := attr.(*Replacer).element(props...) 66 | el.RemoveAttribute("replace") 67 | return el 68 | } 69 | return el 70 | } 71 | 72 | func DropdownSeperator() h.Element { 73 | return h.Span(h.Class("-mx-1 my-1 h-px bg-muted block")) 74 | } 75 | 76 | func DropdownLabel(label string, props ...h.I) h.Element { 77 | return h.Span(Join(props, h.Class("px-2 py-1.5 text-xs font-semibold text-muted-foreground"), h.Text(label))...) 78 | } 79 | 80 | func OpenDropdownOn(event string) h.Attribute { 81 | return On(event, "openDropdown()") 82 | } 83 | 84 | var OpenDropdown = OpenDropdownOn("click") 85 | 86 | func CloseDropdownOn(event string, value any) h.Attribute { 87 | return On(event, fn("closeDropdown", value)) 88 | } 89 | 90 | func CloseDropdown(value string) h.Attribute { 91 | return CloseDropdownOn("click", value) 92 | } 93 | 94 | func DismissDropdownOn(event string) h.Attribute { 95 | return On(event, "dismissDropdown()") 96 | } 97 | 98 | var DismissDropdown = DismissDropdownOn("click") 99 | -------------------------------------------------------------------------------- /ui/components/input.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | "github.com/canpacis/pacis/ui/icons" 6 | ) 7 | 8 | func Input(props ...h.I) h.Element { 9 | input := h.Inpt( 10 | Join( 11 | props, 12 | h.Class("flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm appearance-none"), 13 | )..., 14 | ) 15 | typ, hastyp := input.GetAttribute("type") 16 | class, hascls := h.S(props...).GetAttribute("class") 17 | 18 | return h.Div( 19 | h.Class("relative h-fit w-full"), 20 | h.If(hascls, class), 21 | 22 | D{"input": ""}, 23 | X("init", "input = $el.querySelector('input')"), 24 | 25 | input, 26 | h.IfFn(hastyp, func() h.Renderer { 27 | // input[type=number] 28 | return h.If(readattr(typ) == "number", h.Span( 29 | h.Class("absolute top-0 right-0 h-full flex flex-col text-muted-foreground"), 30 | 31 | h.Btn( 32 | h.Type("button"), 33 | h.Class("bg-transparent border-l border-b flex items-center justify-center px-1 flex-1 hover:bg-accent/50"), 34 | On("click", "input.value = Number(input.value) + 1"), 35 | 36 | icons.ChevronUp(h.Class("size-3.5 pointer-events-none")), 37 | ), 38 | h.Btn( 39 | h.Type("button"), 40 | h.Class("bg-transparent border-l flex items-center justify-center px-1 flex-1 hover:bg-accent/50"), 41 | On("click", "input.value = Number(input.value) - 1"), 42 | 43 | icons.ChevronDown(h.Class("size-3.5 pointer-events-none")), 44 | ), 45 | )) 46 | }), 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /ui/components/label.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import h "github.com/canpacis/pacis/ui/html" 4 | 5 | func Label(text string, props ...h.I) h.Element { 6 | return h.Lbl( 7 | Join(props, 8 | h.Class("text-sm w-full font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 block flex-1 [&>div>input]:mt-2"), 9 | h.Span(h.Class("inline-block"), h.Text(text)), 10 | )..., 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /ui/components/radio.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "fmt" 5 | 6 | h "github.com/canpacis/pacis/ui/html" 7 | ) 8 | 9 | func RadioGroup(name h.Attribute, props ...h.I) h.Element { 10 | el := h.El("fieldset", 11 | Join( 12 | props, 13 | h.Class("space-y-2"), 14 | )..., 15 | ) 16 | 17 | var value string 18 | valueattr, hasvalue := el.GetAttribute("value") 19 | if hasvalue { 20 | value = readattr(valueattr) 21 | } 22 | 23 | id := getid(el) 24 | 25 | el.AddAttribute(X("data", fn("radio", readattr(name), value, id))) 26 | return el 27 | } 28 | 29 | func RadioGroupItem(value h.Attribute, props ...h.I) h.Element { 30 | val := readattr(value) 31 | 32 | return h.Lbl( 33 | Join( 34 | props, 35 | h.Class("flex gap-2 text-sm cursor-pointer items-center"), 36 | h.Div( 37 | h.Class("aspect-square h-4 w-4 rounded-full flex justify-center items-center border border-primary text-primary shadow focus:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"), 38 | 39 | h.Span( 40 | X("show", fmt.Sprintf("value === '%s'", val)), 41 | h.Class("h-3 w-3 bg-primary rounded-full block"), 42 | ), 43 | h.Inpt( 44 | h.Type("radio"), 45 | h.Class("sr-only"), 46 | X("bind:name", "name"), 47 | h.Value(val), 48 | On("change", fmt.Sprintf("value = '%s'", val)), 49 | ), 50 | ), 51 | )..., 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /ui/components/select.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | "github.com/canpacis/pacis/ui/icons" 6 | ) 7 | 8 | func Select(name h.Attribute, props ...h.I) h.Element { 9 | el := h.Div( 10 | Join( 11 | props, 12 | X("trap.noscroll", "open"), 13 | h.Class("relative"), 14 | DismissSelectOn("keydown.esc.window"), 15 | 16 | Input(name, X("bind:value", "value"), h.Class("sr-only")), 17 | )..., 18 | ) 19 | 20 | var value string 21 | valueattr, hasvalue := el.GetAttribute("value") 22 | if hasvalue { 23 | value = readattr(valueattr) 24 | } 25 | 26 | _, clearable := el.GetAttribute("clearable") 27 | 28 | id := getid(el) 29 | 30 | el.AddAttribute(X("data", fn("select", value, clearable, id))) 31 | 32 | return el 33 | } 34 | 35 | func SelectTrigger(trigger h.Element, selected h.Element) h.Element { 36 | selected.AddAttribute(X("show", "value !== null")) 37 | trigger.AddAttribute(X("show", "value === null")) 38 | 39 | return h.Div( 40 | h.Class("relative"), 41 | 42 | h.Btn( 43 | h.Class("flex flex-1 h-9 w-full items-center whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1 pr-16"), 44 | X("ref", "anchor"), 45 | OpenSelect, 46 | 47 | trigger, 48 | selected, 49 | ), 50 | h.Span( 51 | h.Class("absolute top-0 bottom-0 right-2 my-auto flex gap-2 justify-center items-center pointer-events-none"), 52 | 53 | Button( 54 | ButtonSizeIcon, 55 | ButtonVariantGhost, 56 | h.Class("w-5 h-5 pointer-events-auto"), 57 | X("show", "value !== null && clearable"), 58 | SetSelectOn("click", nil), 59 | 60 | icons.X(h.Class("size-4 opacity-50")), 61 | ), 62 | icons.ChevronDown(h.Class("size-4 opacity-50")), 63 | ), 64 | ) 65 | } 66 | 67 | func SelectContent(props ...h.I) h.Element { 68 | return h.Div( 69 | Join( 70 | props, 71 | h.Class("z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin] data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1 p-1 w-full"), 72 | h.Data(":state", "open ? 'open' : 'closed'"), 73 | X("cloak"), 74 | X("show", "open || usedKeyboard"), 75 | X("transition"), 76 | X("trap", "usedKeyboard"), 77 | Anchor(VBottom, HStart, 8), 78 | DismissSelectOn("click.outside"), 79 | On("keydown.down.prevent", "$focus.wrap().next();"), 80 | On("keydown.up.prevent", "$focus.wrap().previous();"), 81 | )..., 82 | ) 83 | } 84 | 85 | func SelectItem(value h.Attribute, props ...h.I) h.Element { 86 | el := h.Btn( 87 | Join( 88 | props, 89 | CloseSelect(readattr(value)), 90 | h.Class("relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent hover:bg-accent focus:text-accent-foreground hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"), 91 | )..., 92 | ) 93 | 94 | return el 95 | } 96 | 97 | func SelectSeperator() h.Element { 98 | return h.Span(h.Class("-mx-1 my-1 h-px bg-muted block")) 99 | } 100 | 101 | func SelectLabel(label string, props ...h.I) h.Element { 102 | return h.Span(Join(props, h.Class("px-2 py-1.5 text-xs font-semibold text-muted-foreground"), h.Text(label))...) 103 | } 104 | 105 | func OpenSelectOn(event string) h.Attribute { 106 | return On(event, "openSelect()") 107 | } 108 | 109 | var OpenSelect = OpenSelectOn("click") 110 | 111 | func CloseSelectOn(event, value string) h.Attribute { 112 | return On(event, fn("closeSelect", value)) 113 | } 114 | 115 | func CloseSelect(value string) h.Attribute { 116 | return CloseSelectOn("click", value) 117 | } 118 | 119 | func DismissSelectOn(event string) h.Attribute { 120 | return On(event, "dismissSelect()") 121 | } 122 | 123 | var DismissSelect = DismissSelectOn("click") 124 | 125 | func SetSelectOn(event string, value any) h.Attribute { 126 | return On(event, fn("setSelect", value)) 127 | } 128 | -------------------------------------------------------------------------------- /ui/components/seperator.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import h "github.com/canpacis/pacis/ui/html" 4 | 5 | func Seperator(orientation Orientation, props ...h.I) h.Element { 6 | return h.Div( 7 | Join( 8 | props, 9 | h.Role("none"), 10 | h.Data("orientation", orientation.String()), 11 | h.Class("bg-border my-2"), 12 | h.If(orientation == OHorizontal, h.Class("h-[1px] w-full")), 13 | h.If(orientation == OVertical, h.Class("h-full w-[1px]")), 14 | )..., 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /ui/components/sheet.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | ) 6 | 7 | var ( 8 | SheetVariantLeft = &GroupedClass{ 9 | "sheet-variant", 10 | "top-0 bottom-0 left-0 h-dvh w-3/4 border-r sm:max-w-sm", 11 | true, 12 | } 13 | SheetVariantTop = &GroupedClass{ 14 | "sheet-variant", 15 | "left-0 right-0 top-0 border-b", 16 | false, 17 | } 18 | SheetVariantRight = &GroupedClass{ 19 | "sheet-variant", 20 | "top-0 bottom-0 right-0 h-dvh w-3/4 border-l sm:max-w-sm", 21 | false, 22 | } 23 | SheetVariantBottom = &GroupedClass{ 24 | "sheet-variant", 25 | "left-0 right-0 bottom-0 border-t", 26 | false, 27 | } 28 | ) 29 | 30 | func Sheet(props ...h.I) h.Element { 31 | el := h.Div(props...) 32 | 33 | _, isopen := el.GetAttribute("open") 34 | if isopen { 35 | el.RemoveAttribute("open") 36 | } 37 | id := getid(el) 38 | el.AddAttribute(X("data", fn("sheet", isopen, id))) 39 | return el 40 | } 41 | 42 | func SheetTrigger(trigger h.Element) h.Element { 43 | trigger.AddAttribute(OpenSheet) 44 | return trigger 45 | } 46 | 47 | func SheetContent(props ...h.I) h.Node { 48 | props = Join( 49 | props, 50 | SheetVariantLeft, 51 | X("cloak"), 52 | X("show", "open"), 53 | X("trap.noscroll", "open"), 54 | CloseSheetOn("click.outside"), 55 | h.Data(":state", "open ? 'opened' : 'closed'"), 56 | h.Class("fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out"), 57 | ) 58 | 59 | content := h.Div(props...) 60 | 61 | for _, prop := range props { 62 | grouped, ok := prop.(*groupedclasses) 63 | if ok { 64 | variant := grouped.Candidate() 65 | 66 | switch variant { 67 | case SheetVariantLeft: 68 | content.AddAttribute(X("transition:enter", "-translate-x-[100%]")) 69 | content.AddAttribute(X("transition:leave", "-translate-x-[100%]")) 70 | case SheetVariantTop: 71 | content.AddAttribute(X("transition:enter", "-translate-y-[100%]")) 72 | content.AddAttribute(X("transition:leave", "-translate-y-[100%]")) 73 | case SheetVariantRight: 74 | content.AddAttribute(X("transition:enter", "translate-x-[100%]")) 75 | content.AddAttribute(X("transition:leave", "translate-x-[100%]")) 76 | case SheetVariantBottom: 77 | content.AddAttribute(X("transition:enter", "translate-y-[100%]")) 78 | content.AddAttribute(X("transition:leave", "translate-y-[100%]")) 79 | } 80 | } 81 | } 82 | 83 | return h.Frag( 84 | // Overlay 85 | h.Div( 86 | X("cloak"), 87 | X("show", "open"), 88 | h.Data(":state", "open ? 'opened' : 'closed'"), 89 | h.Class("fixed h-dvh inset-0 z-50 bg-black/80"), 90 | X("transition.opacity"), 91 | ), 92 | content, 93 | ) 94 | } 95 | 96 | func OpenSheetOn(event string) h.Attribute { 97 | return On(event, "openSheet()") 98 | } 99 | 100 | var OpenSheet = OpenSheetOn("click") 101 | 102 | func CloseSheetOn(event string) h.Attribute { 103 | return On(event, "closeSheet()") 104 | } 105 | 106 | var CloseSheet = CloseSheetOn("click") 107 | -------------------------------------------------------------------------------- /ui/components/slider.go: -------------------------------------------------------------------------------- 1 | package components 2 | -------------------------------------------------------------------------------- /ui/components/src/main.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss" source("../"); 2 | 3 | @plugin "tailwindcss-animate"; 4 | 5 | @custom-variant dark (&:is(.dark *)); 6 | 7 | @theme inline { 8 | --color-background: var(--background); 9 | --color-foreground: var(--foreground); 10 | --font-sans: "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji"; 11 | --color-sidebar-ring: var(--sidebar-ring); 12 | --color-sidebar-border: var(--sidebar-border); 13 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 14 | --color-sidebar-accent: var(--sidebar-accent); 15 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 16 | --color-sidebar-primary: var(--sidebar-primary); 17 | --color-sidebar-foreground: var(--sidebar-foreground); 18 | --color-sidebar: var(--sidebar); 19 | --color-chart-5: var(--chart-5); 20 | --color-chart-4: var(--chart-4); 21 | --color-chart-3: var(--chart-3); 22 | --color-chart-2: var(--chart-2); 23 | --color-chart-1: var(--chart-1); 24 | --color-ring: var(--ring); 25 | --color-input: var(--input); 26 | --color-border: var(--border); 27 | --color-destructive: var(--destructive); 28 | --color-accent-foreground: var(--accent-foreground); 29 | --color-accent: var(--accent); 30 | --color-muted-foreground: var(--muted-foreground); 31 | --color-muted: var(--muted); 32 | --color-secondary-foreground: var(--secondary-foreground); 33 | --color-secondary: var(--secondary); 34 | --color-primary-foreground: var(--primary-foreground); 35 | --color-primary: var(--primary); 36 | --color-popover-foreground: var(--popover-foreground); 37 | --color-popover: var(--popover); 38 | --color-card-foreground: var(--card-foreground); 39 | --color-card: var(--card); 40 | --radius-sm: calc(var(--radius) - 4px); 41 | --radius-md: calc(var(--radius) - 2px); 42 | --radius-lg: var(--radius); 43 | --radius-xl: calc(var(--radius) + 4px); 44 | } 45 | 46 | :root { 47 | --radius: 0.625rem; 48 | --background: oklch(1 0 0); 49 | --foreground: oklch(0.145 0 0); 50 | --card: oklch(1 0 0); 51 | --card-foreground: oklch(0.145 0 0); 52 | --popover: oklch(1 0 0); 53 | --popover-foreground: oklch(0.145 0 0); 54 | --primary: oklch(0.205 0 0); 55 | --primary-foreground: oklch(0.985 0 0); 56 | --secondary: oklch(0.97 0 0); 57 | --secondary-foreground: oklch(0.205 0 0); 58 | --muted: oklch(0.97 0 0); 59 | --muted-foreground: oklch(0.556 0 0); 60 | --accent: oklch(0.97 0 0); 61 | --accent-foreground: oklch(0.205 0 0); 62 | --destructive: oklch(0.577 0.245 27.325); 63 | --border: oklch(0.922 0 0); 64 | --input: oklch(0.922 0 0); 65 | --ring: oklch(0.708 0 0); 66 | --chart-1: oklch(0.55 0.22 263); 67 | --chart-2: oklch(0.56 0.25 302); 68 | --chart-3: oklch(0% 0.0025 29.23); 69 | --chart-4: oklch(67.81% 0.2159 39.29); 70 | --chart-5: oklch(62.8% 0.2577 29.23); 71 | --sidebar: oklch(0.985 0 0); 72 | --sidebar-foreground: oklch(0.145 0 0); 73 | --sidebar-primary: oklch(0.205 0 0); 74 | --sidebar-primary-foreground: oklch(0.985 0 0); 75 | --sidebar-accent: oklch(0.97 0 0); 76 | --sidebar-accent-foreground: oklch(0.205 0 0); 77 | --sidebar-border: oklch(0.922 0 0); 78 | --sidebar-ring: oklch(0.708 0 0); 79 | --gradient-bg-1: hsl(213.8 100% 96.9%); 80 | --gradient-bg-2: hsl(270 100% 98%); 81 | } 82 | 83 | .dark { 84 | --background: oklch(0.145 0 0); 85 | --foreground: oklch(0.985 0 0); 86 | --card: oklch(0.205 0 0); 87 | --card-foreground: oklch(0.985 0 0); 88 | --popover: oklch(0.205 0 0); 89 | --popover-foreground: oklch(0.985 0 0); 90 | --primary: oklch(0.922 0 0); 91 | --primary-foreground: oklch(0.205 0 0); 92 | --secondary: oklch(0.269 0 0); 93 | --secondary-foreground: oklch(0.985 0 0); 94 | --muted: oklch(0.269 0 0); 95 | --muted-foreground: oklch(0.708 0 0); 96 | --accent: oklch(0.269 0 0); 97 | --accent-foreground: oklch(0.985 0 0); 98 | --destructive: oklch(0.704 0.191 22.216); 99 | --border: oklch(1 0 0 / 10%); 100 | --input: oklch(1 0 0 / 15%); 101 | --ring: oklch(0.556 0 0); 102 | --chart-1: oklch(0.55 0.22 263); 103 | --chart-2: oklch(0.56 0.25 302); 104 | --chart-3: oklch(100% 0.0025 29.23); 105 | --chart-4: oklch(67.81% 0.2159 39.29); 106 | --chart-5: oklch(62.8% 0.2577 29.23); 107 | --sidebar: oklch(0.205 0 0); 108 | --sidebar-foreground: oklch(0.985 0 0); 109 | --sidebar-primary: oklch(0.488 0.243 264.376); 110 | --sidebar-primary-foreground: oklch(0.985 0 0); 111 | --sidebar-accent: oklch(0.269 0 0); 112 | --sidebar-accent-foreground: oklch(0.985 0 0); 113 | --sidebar-border: oklch(1 0 0 / 10%); 114 | --sidebar-ring: oklch(0.556 0 0); 115 | --gradient-bg-1: hsl(226.2 57% 9%); 116 | --gradient-bg-2: hsl(273.5 86.9% 9%); 117 | } 118 | 119 | @layer base { 120 | body { 121 | @apply bg-background text-foreground; 122 | } 123 | 124 | ::-webkit-resizer { 125 | @apply hidden; 126 | } 127 | } 128 | 129 | @layer utilities { 130 | * { 131 | @apply border-border outline-ring/50; 132 | } 133 | } 134 | 135 | input::-webkit-outer-spin-button, 136 | input::-webkit-inner-spin-button { 137 | -webkit-appearance: none; 138 | margin: 0; 139 | } 140 | 141 | input[type="number"] { 142 | -moz-appearance: textfield; 143 | } 144 | 145 | [x-cloak] { display: none !important; } -------------------------------------------------------------------------------- /ui/components/switch.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import h "github.com/canpacis/pacis/ui/html" 4 | 5 | func Switch(props ...h.I) h.Element { 6 | el := h.S(props...) 7 | _, checked := el.GetAttribute("checked") 8 | id := getid(el) 9 | 10 | return h.Lbl( 11 | Join( 12 | props, 13 | h.Class("flex gap-2 text-sm cursor-pointer"), 14 | X("data", fn("appswitch", checked, id)), 15 | h.Div( 16 | h.Data(":state", "checked ? 'checked' : 'unchecked'"), 17 | h.Class("peer inline-flex h-5 w-9 shrink-0 items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input"), 18 | h.Span( 19 | h.Data(":state", "checked ? 'checked' : 'unchecked'"), 20 | h.Class("pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"), 21 | ), 22 | h.Inpt( 23 | h.ID(id), 24 | h.Type("checkbox"), 25 | h.Class("sr-only"), 26 | X("bind:checked", "checked"), 27 | ToggleSwitchOn("change"), 28 | ), 29 | ), 30 | )..., 31 | ) 32 | } 33 | 34 | // Returns an attribute that toggles the related switch upon given event 35 | func ToggleSwitchOn(event string) h.Attribute { 36 | return On(event, "toggleSwitch()") 37 | } 38 | 39 | // An attribute that toggles the related switch on click 40 | var ToggleSwitch = ToggleSwitchOn("click") 41 | -------------------------------------------------------------------------------- /ui/components/table.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import h "github.com/canpacis/pacis/ui/html" 4 | 5 | func Table(props ...h.I) h.Element { 6 | return h.Div( 7 | h.Class("relative w-full overflow-auto"), 8 | 9 | h.Tble( 10 | Join( 11 | props, 12 | h.Class("w-full caption-bottom text-sm"), 13 | )..., 14 | ), 15 | ) 16 | } 17 | 18 | func TableHeader(props ...h.I) h.Element { 19 | return h.Th(Join(props, h.Class("[&_tr]:border-b p-2"))...) 20 | } 21 | 22 | func TableBody(props ...h.I) h.Element { 23 | return h.Tbody(Join(props, h.Class("[&_tr:last-child]:border-0"))...) 24 | } 25 | 26 | func TableRow(props ...h.I) h.Element { 27 | return h.Tr( 28 | Join( 29 | props, 30 | h.Class("border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted"), 31 | )..., 32 | ) 33 | } 34 | 35 | func TableCell(props ...h.I) h.Element { 36 | return h.Td( 37 | Join( 38 | props, 39 | h.Class("p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]"), 40 | )..., 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /ui/components/tabs.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "fmt" 5 | 6 | h "github.com/canpacis/pacis/ui/html" 7 | ) 8 | 9 | func Tabs(props ...h.I) h.Element { 10 | el := h.Div(props...) 11 | valueattr, _ := el.GetAttribute("value") 12 | var value any 13 | if valueattr != nil { 14 | value = readattr(valueattr) 15 | } 16 | id := getid(el) 17 | el.AddAttribute(X("data", fn("tabs", value, id))) 18 | 19 | return el 20 | } 21 | 22 | func TabList(props ...h.I) h.Element { 23 | return h.Div( 24 | Join( 25 | props, 26 | h.Class("border-b flex gap-0"), 27 | )..., 28 | ) 29 | } 30 | 31 | func TabTrigger(trigger h.Node, props ...h.I) h.Element { 32 | el := h.Btn( 33 | Join(props, 34 | h.Class("relative text-sm h-8 text-muted-foreground font-medium px-4 cursor-pointer after:content-[''] after:w-full after:h-px after:absolute after:left-0 after:-bottom-px after:transition-colors focus-visible:outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] rounded-t-sm"), 35 | trigger, 36 | )..., 37 | ) 38 | valueattr, ok := el.GetAttribute("value") 39 | if !ok { 40 | panic("tab trigger elements need a value attribute") 41 | } 42 | value := readattr(valueattr) 43 | el.AddAttribute(SetTab(value)) 44 | el.AddAttribute(X("bind:class", fmt.Sprintf("value === '%s' && 'after:bg-primary !text-primary'", value))) 45 | 46 | return el 47 | } 48 | 49 | func TabContent(props ...h.I) h.Element { 50 | el := h.Div( 51 | Join( 52 | props, 53 | X("cloak"), 54 | h.Class("mt-3"), 55 | )..., 56 | ) 57 | value, ok := el.GetAttribute("value") 58 | if !ok { 59 | panic("tab content elements need a value attribute") 60 | } 61 | el.AddAttribute(X("show", fmt.Sprintf("value === '%s'", readattr(value)))) 62 | 63 | return el 64 | } 65 | 66 | func SetTabOn(event string, value any) h.Attribute { 67 | return On(event, fn("setTab", value)) 68 | } 69 | 70 | func SetTab(value any) h.Attribute { 71 | return SetTabOn("click", value) 72 | } 73 | -------------------------------------------------------------------------------- /ui/components/textarea.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | "github.com/canpacis/pacis/ui/icons" 6 | ) 7 | 8 | func Textarea(props ...h.I) h.Element { 9 | return h.Lbl( 10 | h.Class("relative"), 11 | 12 | h.Txtarea( 13 | Join( 14 | props, 15 | h.Class("flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"), 16 | )..., 17 | ), 18 | h.Span( 19 | h.Class("absolute bottom-px right-px pr-0.5 rounded-sm pointer-events-none bg-background size-fit"), 20 | icons.GripHorizontal(h.Class("size-4 stroke-1 text-muted-foreground")), 21 | ), 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /ui/components/toast.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | h "github.com/canpacis/pacis/ui/html" 5 | "github.com/canpacis/pacis/ui/icons" 6 | ) 7 | 8 | func ToastContainer(props ...h.I) h.I { 9 | return h.Div( 10 | Join( 11 | props, 12 | h.Class("fixed top-0 left-0 p-6 w-dvw h-dvh flex justify-center md:justify-end items-end size-fit z-50 pointer-events-none"), 13 | 14 | h.Div( 15 | h.Class("flex flex-col gap-2"), 16 | 17 | h.Template( 18 | X("for", "toast in $store.toast.visibleToasts"), 19 | h.Div( 20 | X("data", "toast"), 21 | X("show", "show"), 22 | X("bind:key", "toast.id"), 23 | X("transition.delay"), 24 | h.Class("pointer-events-auto relative border rounded-md p-4 w-[74vw] md:w-90 bg-background text-sm transition-opacity"), 25 | 26 | h.P(Textx("toast.content.title")), 27 | h.Span(X("show", "toast.content.message.length > 0"), h.Class("text-muted-foreground mt-1"), Textx("toast.content.message")), 28 | Button( 29 | On("click", "$store.toast.clear(toast.id)"), 30 | h.Class("absolute top-2 right-2 w-6 h-6"), 31 | ButtonSizeIcon, 32 | ButtonVariantGhost, 33 | 34 | icons.X(h.Class("size-3")), 35 | ), 36 | ), 37 | ), 38 | ), 39 | )..., 40 | ) 41 | } 42 | 43 | func ShowToastOn(event, title, message string) h.Attribute { 44 | return On(event, fn("$store.toast.show", title, message)) 45 | } 46 | 47 | func ShowToast(title, message string) h.Attribute { 48 | return ShowToastOn("click", title, message) 49 | } 50 | -------------------------------------------------------------------------------- /ui/components/tooltip.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "time" 5 | 6 | h "github.com/canpacis/pacis/ui/html" 7 | ) 8 | 9 | func Tooltip(content string, delay time.Duration, trigger h.Element, props ...h.I) h.Node { 10 | trigger.AddAttribute(X("ref", "anchor")) 11 | 12 | return h.Div( 13 | X("data", "tooltip"), 14 | QueueOpenTooltip(delay), 15 | AbortTooltip, 16 | 17 | h.Span( 18 | Join( 19 | props, 20 | X("cloak"), 21 | X("show", "open"), 22 | X("transition:enter-start", "scale-90 opacity-0"), 23 | X("transition:enter-end", "scale-100 opacity-100"), 24 | X("transition:leave-start", "scale-100 opacity-100"), 25 | X("transition:leave-end", "scale-90 opacity-0"), 26 | Anchor(VTop, HCenter, 8), 27 | h.Class("z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground transition ease-in-out duration-100 pointer-events-none"), 28 | 29 | h.Text(content), 30 | )..., 31 | ), 32 | trigger, 33 | ) 34 | } 35 | 36 | func OpenTooltipOn(event string) h.Attribute { 37 | return On(event, "openTooltip()") 38 | } 39 | 40 | var OpenTooltip = OpenTooltipOn("mouseenter") 41 | 42 | func CloseTooltipOn(event string) h.Attribute { 43 | return On(event, "closeTooltip()") 44 | } 45 | 46 | var CloseTooltip = CloseTooltipOn("mouseleave") 47 | 48 | func QueueOpenTooltipOn(event string, delay time.Duration) h.Attribute { 49 | return On(event, fn("queueOpenTooltip", int(delay.Milliseconds()))) 50 | } 51 | 52 | func QueueOpenTooltip(delay time.Duration) h.Attribute { 53 | return QueueOpenTooltipOn("mouseenter", delay) 54 | } 55 | 56 | func AbortTooltipOn(event string) h.Attribute { 57 | return On(event, "abortTooltip()") 58 | } 59 | 60 | var AbortTooltip = AbortTooltipOn("mouseleave") 61 | -------------------------------------------------------------------------------- /ui/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/canpacis/pacis/ui 2 | 3 | go 1.24.1 4 | 5 | require github.com/alecthomas/chroma/v2 v2.17.0 6 | 7 | require github.com/dlclark/regexp2 v1.11.5 // indirect 8 | -------------------------------------------------------------------------------- /ui/go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 2 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/chroma/v2 v2.17.0 h1:3r2Cgk+nXNICMBxIFGnTRTbQFUwMiLisW+9uos0TtUI= 4 | github.com/alecthomas/chroma/v2 v2.17.0/go.mod h1:RVX6AvYm4VfYe/zsk7mjHueLDZor3aWCNE14TFlepBk= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= 8 | github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 9 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 10 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 11 | -------------------------------------------------------------------------------- /ui/icons/generator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "go/format" 7 | "log" 8 | "os" 9 | "path" 10 | "slices" 11 | "strings" 12 | "unicode" 13 | ) 14 | 15 | func toPascalCase(s string) string { 16 | words := strings.FieldsFunc(s, func(r rune) bool { 17 | return !unicode.IsLetter(r) && !unicode.IsDigit(r) 18 | }) 19 | 20 | for i, word := range words { 21 | words[i] = strings.ToUpper(string(word[0])) + strings.ToLower(word[1:]) 22 | } 23 | 24 | return strings.Join(words, "") 25 | } 26 | 27 | func template(name string, content []byte) string { 28 | c := []string{} 29 | 30 | for _, b := range content { 31 | c = append(c, fmt.Sprintf("%d", b)) 32 | } 33 | 34 | return fmt.Sprintf(`func %s(props ...h.I) h.Node { return Icon(join(props, r([]byte{%s}))...) }`, toPascalCase(name), strings.Join(c, ", ")) 35 | } 36 | 37 | type SvgIcon struct { 38 | Content []byte `xml:",innerxml"` 39 | } 40 | 41 | func main() { 42 | os.Remove("icons.go") 43 | file, err := os.OpenFile("icons.go", os.O_CREATE|os.O_RDWR, 0o644) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | defer file.Close() 48 | 49 | dir := "../lucide/icons" 50 | entries, err := os.ReadDir(dir) 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | 55 | program := "package icons\n\nimport h \"github.com/canpacis/pacis/ui/html\"\n\ntype r = h.RawUnsafe\n\n" 56 | 57 | for _, entry := range entries { 58 | name := entry.Name() 59 | ext := path.Ext(name) 60 | if ext != ".svg" { 61 | continue 62 | } 63 | 64 | var icon SvgIcon 65 | file, err := os.OpenFile(path.Join(dir, name), os.O_RDONLY, 0o644) 66 | if err != nil { 67 | log.Fatal(err) 68 | } 69 | decoder := xml.NewDecoder(file) 70 | if err := decoder.Decode(&icon); err != nil { 71 | log.Fatal(err) 72 | } 73 | icon.Content = slices.DeleteFunc(icon.Content, func(b byte) bool { 74 | return b == 10 75 | }) 76 | 77 | program += template(strings.TrimSuffix(name, ext), icon.Content) 78 | program += "\n" 79 | } 80 | 81 | formatted, err := format.Source([]byte(program)) 82 | if err != nil { 83 | log.Fatal(err) 84 | } 85 | _, err = file.Write(formatted) 86 | if err != nil { 87 | log.Fatal(err) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /ui/icons/icon.go: -------------------------------------------------------------------------------- 1 | //go:generate go run ./generator/main.go 2 | package icons 3 | 4 | import ( 5 | "context" 6 | "io" 7 | "strconv" 8 | 9 | h "github.com/canpacis/pacis/ui/html" 10 | ) 11 | 12 | func join(props []h.I, rest ...h.I) []h.I { 13 | return append(rest, props...) 14 | } 15 | 16 | type Width float64 17 | 18 | func (Width) GetKey() string { 19 | return "width" 20 | } 21 | 22 | func (wd Width) IsEmpty() bool { 23 | return false 24 | } 25 | 26 | // Implements Deduper interface to deduplicate attribute 27 | // and use the last provided value as the final attribte 28 | func (Width) Dedupe() {} 29 | 30 | func (wd Width) Render(ctx context.Context, w io.Writer) error { 31 | _, err := w.Write([]byte(strconv.FormatFloat(float64(wd), 'f', -1, 64))) 32 | return err 33 | } 34 | 35 | type Height float64 36 | 37 | func (Height) GetKey() string { 38 | return "height" 39 | } 40 | 41 | func (wd Height) IsEmpty() bool { 42 | return false 43 | } 44 | 45 | // Implements Deduper interface to deduplicate attribute 46 | // and use the last provided value as the final attribte 47 | func (Height) Dedupe() {} 48 | 49 | func (wd Height) Render(ctx context.Context, w io.Writer) error { 50 | _, err := w.Write([]byte(strconv.FormatFloat(float64(wd), 'f', -1, 64))) 51 | return err 52 | } 53 | 54 | type StrokeWidth float64 55 | 56 | func (StrokeWidth) GetKey() string { 57 | return "stroke-width" 58 | } 59 | 60 | func (wd StrokeWidth) IsEmpty() bool { 61 | return false 62 | } 63 | 64 | // Implements Deduper interface to deduplicate attribute 65 | // and use the last provided value as the final attribte 66 | func (StrokeWidth) Dedupe() {} 67 | 68 | func (wd StrokeWidth) Render(ctx context.Context, w io.Writer) error { 69 | _, err := w.Write([]byte(strconv.FormatFloat(float64(wd), 'f', -1, 64))) 70 | return err 71 | } 72 | 73 | func Fill(fill string) h.Attribute { 74 | return h.Attr("fill", fill) 75 | } 76 | 77 | func Stroke(fill string) h.Attribute { 78 | return h.Attr("stroke", fill) 79 | } 80 | 81 | type SvgIcon struct { 82 | h.Element 83 | } 84 | 85 | func Icon(props ...h.I) SvgIcon { 86 | props = join(props, 87 | Width(24), 88 | Height(24), 89 | StrokeWidth(2), 90 | Fill("none"), 91 | Stroke("currentColor"), 92 | h.Attr("viewBox", "0 0 24 24"), 93 | h.Attr("stroke-linecap", "round"), 94 | h.Attr("stroke-linejoin", "round"), 95 | ) 96 | return SvgIcon{Element: h.El("svg", props...)} 97 | } 98 | -------------------------------------------------------------------------------- /ui/icons/icon_test.go: -------------------------------------------------------------------------------- 1 | package icons_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "testing" 7 | 8 | "github.com/canpacis/pacis/ui/icons" 9 | ) 10 | 11 | func TestIcon(t *testing.T) { 12 | buf := bytes.NewBuffer([]byte{}) 13 | icon := icons.Search() 14 | err := icon.Render(context.Background(), buf) 15 | if err != nil { 16 | t.Errorf("icon test failed: %s", err.Error()) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /www/app/assets/banner.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canpacis/pacis/89628a27959d1c40c80a278f3ca808af72edf5f0/www/app/assets/banner.webp -------------------------------------------------------------------------------- /www/app/assets/favicon.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canpacis/pacis/89628a27959d1c40c80a278f3ca808af72edf5f0/www/app/assets/favicon.webp -------------------------------------------------------------------------------- /www/app/assets/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/canpacis/pacis/89628a27959d1c40c80a278f3ca808af72edf5f0/www/app/assets/logo.webp -------------------------------------------------------------------------------- /www/app/assets/main.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | @custom-variant dark (&:is(.dark *)); 4 | 5 | @theme inline { 6 | --header-height: 4rem; 7 | --footer-height: 2.5rem; 8 | --font-sans: "Inter"; 9 | --font-mono: "JetBrains Mono", monospace; 10 | --color-ring: var(--ring); 11 | } 12 | 13 | 14 | :root { 15 | --ring: oklch(0.708 0 0); 16 | } 17 | 18 | .dark { 19 | --ring: oklch(0.556 0 0); 20 | } 21 | 22 | @layer base { 23 | .container { 24 | @apply md:!max-w-[880px] lg:!max-w-[1140px] xl:!max-w-[1420px] !max-w-[94dvw] w-full mx-auto px-4; 25 | } 26 | .focusable { 27 | @apply focus-visible:outline-none focus-visible:ring-ring/50 focus-visible:ring-[3px] rounded-sm; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /www/app/assets/main.ts: -------------------------------------------------------------------------------- 1 | window.addEventListener("DOMContentLoaded", () => { 2 | document.querySelectorAll("a").forEach((a) => { 3 | if (a.host !== window.location.host && a.href.startsWith("http")) { 4 | a.setAttribute("data-umami-event", "outbound-link-click"); 5 | a.setAttribute("data-umami-event-url", a.href); 6 | } 7 | }); 8 | }); 9 | 10 | window.addEventListener("alpine:init", () => { 11 | window.addEventListener("DOMContentLoaded", () => { 12 | const user = Alpine.store("user"); 13 | if (user.logged_in) 14 | window.umami.identify({ 15 | email: user.email, 16 | name: user.name, 17 | id: user.id, 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /www/app/auth.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/hex" 7 | "encoding/json" 8 | "fmt" 9 | "net/http" 10 | "os" 11 | "time" 12 | 13 | "github.com/authorizerdev/authorizer-go" 14 | "github.com/canpacis/pacis/pages" 15 | . "github.com/canpacis/pacis/ui/components" 16 | . "github.com/canpacis/pacis/ui/html" 17 | "github.com/redis/go-redis/v9" 18 | ) 19 | 20 | var ( 21 | cachedb *redis.Client 22 | auth *authorizer.AuthorizerClient 23 | ) 24 | 25 | type CacheStorage struct { 26 | db *redis.Client 27 | } 28 | 29 | func (cs *CacheStorage) Get(key string, val any) error { 30 | return cs.db.Get(context.Background(), key).Scan(val) 31 | } 32 | 33 | func (cs *CacheStorage) Set(key string, val any) error { 34 | return cs.db.Set(context.Background(), key, val, time.Hour).Err() 35 | } 36 | 37 | func Init() error { 38 | // oauthConfig = &oauth2.Config{ 39 | // RedirectURL: os.Getenv("OAUTH_CALLBACK_URL"), 40 | // ClientID: os.Getenv("GOOGLE_OAUTH_CLIENT_ID"), 41 | // ClientSecret: os.Getenv("GOOGLE_OAUTH_CLIENT_SECRET"), 42 | // Scopes: []string{"email", "profile"}, 43 | // Endpoint: google.Endpoint, 44 | // } 45 | 46 | cachedb = redis.NewClient(&redis.Options{ 47 | Addr: os.Getenv("REDIS_URL"), 48 | Username: os.Getenv("REDIS_USERNAME"), 49 | Password: os.Getenv("REDIS_PASSWORD"), 50 | DB: 0, 51 | }) 52 | 53 | var err error 54 | auth, err = authorizer.NewAuthorizerClient( 55 | os.Getenv("AUTHORIZER_ID"), 56 | os.Getenv("AUTHORIZER_URL"), 57 | os.Getenv("OAUTH_CALLBACK_URL"), 58 | map[string]string{}, 59 | ) 60 | if err != nil { 61 | return fmt.Errorf("failed to instantiate authorizer: %w", err) 62 | } 63 | return nil 64 | } 65 | 66 | func randstate() string { 67 | b := make([]byte, 32) 68 | rand.Read(b) 69 | return hex.EncodeToString(b) 70 | } 71 | 72 | type User struct { 73 | UserID string `json:"id,omitempty"` 74 | Email string `json:"email,omitempty"` 75 | Name string `json:"name,omitempty"` 76 | Picture string `json:"picture,omitempty"` 77 | LoggedIn bool `json:"logged_in"` 78 | } 79 | 80 | func (u User) ID() string { 81 | return u.UserID 82 | } 83 | 84 | func (u *User) MarshalBinary() ([]byte, error) { 85 | return json.Marshal(u) 86 | } 87 | 88 | func (u *User) UnmarshalBinary(data []byte) error { 89 | return json.Unmarshal(data, u) 90 | } 91 | 92 | //pacis:middleware label=authentication 93 | func AuthHandler(r *http.Request) (*User, error) { 94 | // if oauthConfig == nil { 95 | // return nil, errors.New("no oauth2 config") 96 | // } 97 | // if cachedb == nil { 98 | // return nil, errors.New("no cachedb") 99 | // } 100 | 101 | // cookie, err := r.Cookie("auth_token") 102 | // if err != nil { 103 | // if errors.Is(err, http.ErrNoCookie) { 104 | // return nil, nil 105 | // } 106 | // return nil, err 107 | // } 108 | 109 | // client := oauthConfig.Client(r.Context(), &oauth2.Token{AccessToken: cookie.Value}) 110 | 111 | // user := new(User) 112 | // err = cachedb.Get(r.Context(), cookie.Value).Scan(user) 113 | // if err == nil { 114 | // return user, nil 115 | // } 116 | 117 | // resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") 118 | // if err != nil { 119 | // return nil, err 120 | // } 121 | // defer resp.Body.Close() 122 | 123 | // data, err := io.ReadAll(resp.Body) 124 | // if err != nil { 125 | // return nil, err 126 | // } 127 | 128 | // if err := json.Unmarshal(data, user); err != nil { 129 | // return nil, err 130 | // } 131 | 132 | // user.LoggedIn = true 133 | // err = cachedb.Set(r.Context(), cookie.Value, user, time.Hour).Err() 134 | // if err != nil { 135 | // slog.Error("failed to cache user data", "error", err) 136 | // } 137 | 138 | return nil, nil 139 | } 140 | 141 | type LoginPage struct { 142 | User *User `context:"user"` 143 | } 144 | 145 | //pacis:page path=/auth/login middlewares=auth 146 | func (p *LoginPage) Page(ctx *pages.Context) I { 147 | // if p.User != nil { 148 | // return pages.Redirect(ctx, "/") 149 | // } 150 | 151 | // state := randstate() 152 | // url := oauthConfig.AuthCodeURL(state) 153 | 154 | // pages.SetCookie( 155 | // ctx, 156 | 157 | // &http.Cookie{ 158 | // Name: "auth_state", 159 | // Value: state, 160 | // Path: "/", 161 | // Secure: true, 162 | // HttpOnly: true, 163 | // // Give the state cookie 5 minutes to expire 164 | // Expires: time.Now().Add(time.Minute * 5), 165 | // SameSite: http.SameSiteNoneMode, 166 | // }, 167 | // ) 168 | 169 | return Div( 170 | Class("container flex-1 flex items-center justify-center flex-col gap-4"), 171 | 172 | H1(Class("text-3xl font-semibold"), Text("Welcome to Pacis")), 173 | Form( 174 | Input( 175 | Placeholder("Email"), 176 | Placeholder("Password"), 177 | ), 178 | ), 179 | // Button( 180 | // ButtonSizeLg, 181 | // Href(url), 182 | // Replace(A), 183 | // Class("!rounded-full"), 184 | 185 | // GoogleIcon(), 186 | // Text("Login with Google"), 187 | // ), 188 | ) 189 | } 190 | 191 | type Signup struct { 192 | User *User `context:"user"` 193 | Email string `form:"email"` 194 | Password string `form:"password"` 195 | } 196 | 197 | //pacis:page path=/auth/signup middlewares=auth 198 | func (s *Signup) Page(ctx *pages.Context) I { 199 | if s.User != nil { 200 | return pages.Redirect(ctx, "/") 201 | } 202 | 203 | return Div( 204 | Class("container flex-1 flex justify-center items-center"), 205 | 206 | Div( 207 | Class("max-w-80 flex flex-col gap-4"), 208 | 209 | H1(Class("font-semibold"), Text("Sign up to Pacis")), 210 | Form( 211 | Action("/auth/signup"), 212 | Method("POST"), 213 | Class("flex flex-col gap-2 w-full"), 214 | 215 | Input( 216 | Type("email"), 217 | Attr("required"), 218 | Placeholder("Email"), 219 | Name("email"), 220 | ), 221 | Input( 222 | Type("password"), 223 | Attr("required"), 224 | Placeholder("Password"), 225 | Name("password"), 226 | ), 227 | Button( 228 | Type("submit"), 229 | 230 | Text("Sign Up"), 231 | ), 232 | ), 233 | ), 234 | ) 235 | } 236 | 237 | //pacis:action path=/auth/signup method=post middlewares=auth 238 | func (s *Signup) Action(ctx *pages.Context) I { 239 | token, err := auth.SignUp(&authorizer.SignUpInput{ 240 | Email: &s.Email, 241 | Password: s.Password, 242 | ConfirmPassword: s.Password, 243 | }) 244 | fmt.Println(token, err) 245 | 246 | // return pages.Redirect(ctx, "/") 247 | return Frag() 248 | } 249 | 250 | //pacis:page path=/auth/logout middlewares=auth 251 | func LogoutPage(ctx *pages.Context) I { 252 | pages.SetCookie( 253 | ctx, 254 | 255 | &http.Cookie{ 256 | Name: "auth_token", 257 | Value: "", 258 | Path: "/", 259 | Secure: true, 260 | HttpOnly: true, 261 | SameSite: http.SameSiteNoneMode, 262 | MaxAge: -1, 263 | }, 264 | ) 265 | 266 | return pages.Redirect(ctx, "/") 267 | } 268 | 269 | // type AuthCallbackPage struct { 270 | // Code string `query:"code"` 271 | // QueryState string `query:"state"` 272 | // CookieState string `cookie:"auth_state"` 273 | // } 274 | 275 | // //pacis:page path=/auth/callback middlewares=auth 276 | // func (p *AuthCallbackPage) Page(ctx *pages.Context) I { 277 | // if p.QueryState != p.CookieState { 278 | // return pages.Error(ctx, NewAppError(InvalidAuthStateError, ErrGenericAppError, http.StatusBadRequest)) 279 | // } 280 | 281 | // token, err := oauthConfig.Exchange(ctx, p.Code) 282 | // if err != nil { 283 | // return pages.Error(ctx, NewAppError(AuthExchangeError, ErrGenericAppError, http.StatusBadRequest)) 284 | // } 285 | 286 | // pages.SetCookie( 287 | // ctx, 288 | 289 | // &http.Cookie{ 290 | // Name: "auth_token", 291 | // Value: token.AccessToken, 292 | // Path: "/", 293 | // Secure: true, 294 | // HttpOnly: true, 295 | // SameSite: http.SameSiteLaxMode, 296 | // }, 297 | // ) 298 | // return pages.Redirect(ctx, "/") 299 | // } 300 | -------------------------------------------------------------------------------- /www/app/components/button.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | "github.com/canpacis/pacis/pages" 5 | . "github.com/canpacis/pacis/ui/components" 6 | . "github.com/canpacis/pacis/ui/html" 7 | "github.com/canpacis/pacis/ui/icons" 8 | ) 9 | 10 | func DocButton(href string, next bool, label Node) Element { 11 | return Button( 12 | Href(href), 13 | Replace(pages.A), 14 | Class("h-fit min-w-32 justify-start"), 15 | If(next, Class("ml-auto")), 16 | pages.Eager, 17 | ButtonVariantGhost, 18 | 19 | Span( 20 | Class("flex flex-col gap-px w-full"), 21 | If(next, Class("items-start")), 22 | If(!next, Class("items-end")), 23 | 24 | Span( 25 | Class("text-xs font-light inline"), 26 | 27 | If(next, Text("Up Next")), 28 | If(!next, Text("Previous")), 29 | ), 30 | Span( 31 | Class("text-base font-semibold flex gap-4 items-center w-full"), 32 | 33 | If(!next, icons.ArrowLeft(Class("size-4 mr-auto"))), 34 | label, 35 | If(next, icons.ArrowRight(Class("size-4 ml-auto"))), 36 | ), 37 | ), 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /www/app/components/plate.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | . "github.com/canpacis/pacis/ui/components" 5 | . "github.com/canpacis/pacis/ui/html" 6 | ) 7 | 8 | func Plate(node Node, props ...I) Element { 9 | return Div( 10 | Join( 11 | props, 12 | Class("flex justify-center items-center gap-4 min-h-40 w-full border rounded-lg p-4 md:p-16 my-4"), 13 | 14 | node, 15 | )..., 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /www/app/components/title.go: -------------------------------------------------------------------------------- 1 | package components 2 | 3 | import ( 4 | . "github.com/canpacis/pacis/ui/components" 5 | . "github.com/canpacis/pacis/ui/html" 6 | ) 7 | 8 | func SectionTitle(text Node) Element { 9 | return Div( 10 | Class("my-4"), 11 | 12 | H2( 13 | Class("scroll-m-20 text-xl font-bold tracking-tight"), 14 | 15 | text, 16 | ), 17 | Seperator(OHorizontal), 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /www/app/docs/getting-started/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing -------------------------------------------------------------------------------- /www/app/docs/getting-started/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | -------------------------------------------------------------------------------- /www/app/docs/getting-started/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | {.text-muted-foreground} 4 | PacisUI is a set of utilities that are mainly UI components that help you build beautiful web interfaces with the Go programming language. 5 | 6 | I plan to make PacisUI a part of a bigger set of tools that is Pacis, but for now I only have this documentation. 7 | 8 | ## What this is 9 | 10 | This is a UI library built with [Go Language](https://go.dev/), [TailwindCSS](https://tailwindcss.com/) and [Alpine.js](https://alpinejs.dev/) and it comes in two pieces: 11 | 12 | ### The Renderer 13 | 14 | PacisUI comes with its own html renderer for html elements and their attributes. Think of it like [templ](https://templ.guide/) or [gomponents](https://www.gomponents.com/). If you are familiar with the latter, you will find the PacisUI renderer familiar as well. It looks something like this; 15 | 16 | ```go 17 | Div( 18 | ID("my-div") // An attribute 19 | 20 | P(Text("Hello, World!")) // A child with a child 21 | ) 22 | ``` 23 | 24 | You compose your html with go functions with PacisUI. If you are not sure about writing your html with Go functions, give it a try anyway, it might be for you and I believe you will find it very expressive and liberating. 25 | 26 | > Visit the [Syntax & Usage](/docs/syntax-usage) page to dive deep in this subject. 27 | 28 | ### The Components 29 | 30 | The second piece and the focal point of this library is the components. These are, styled, interactive components that you would mainly be using. 31 | 32 | A web app built with PacisUI ideally would use both these components and the builtin html elements along with some custom styling with tailwind. 33 | 34 | > The truth about frontend development is that these libraries are not a 'be all' solution. It will still require a considerable effort to create something beautiful. 35 | 36 | ### Icons 37 | 38 | A *secret* third piece of the puzzle is the icons. PacisUI comes with a prebuilt icon library that is [Lucide](https://lucide.dev/). Icons are an essential part of UI development and I believe an out of the box solution is always needed. 39 | 40 | That being said, you can always bring your own icons in the form of fonts, SVGs or plain images (although I recommend SVGs). I plan to create a better API to interact with *your* custom SVG icons in the future. 41 | 42 | ## How it works 43 | 44 | A simple overview of this library is that it has nothing more than a bunch of functions that create a meaninful UI's. 45 | 46 | The renderer provides some primitive interfaces and other stuff around it (like the components, icons or your own stuff) consume them. 47 | 48 | These primitives are: 49 | 50 | - `renderer.Renderer`: an interface that any renderer implements. If you have worked with [templ](https://templ.guide/) before, the signature will look familiar. 51 | 52 | 53 | ```go 54 | type Renderer interface { 55 | Render(context.Context, io.Writer) error 56 | } 57 | ``` 58 | 59 | - `renderer.I`: an alias to `renderer.Renderer` for ease of use. 60 | 61 | ```go 62 | type I = Renderer 63 | ``` 64 | 65 | - `renderer.Node`: represents an HTML node that is renderable, this can be anything from an element to a text node. 66 | 67 | ```go 68 | type NodeType int 69 | 70 | const ( 71 | NodeText = NodeType(iota) 72 | NodeElement 73 | NodeFragment 74 | ) 75 | 76 | type Node interface { 77 | Renderer 78 | NodeType() NodeType 79 | } 80 | ``` 81 | 82 | - `renderer.Element`: represents and HTML element but not attributes or texts. 83 | 84 | ```go 85 | type Element interface { 86 | Node 87 | GetTag() string 88 | 89 | GetAttributes() []Attribute 90 | GetAttribute(string) (Attribute, bool) 91 | AddAttribute(Attribute) 92 | RemoveAttribute(string) 93 | 94 | GetNodes() []Node 95 | GetNode(int) (Node, bool) 96 | AddNode(Node) 97 | RemoveNode(int) 98 | 99 | GetElement(int) (Element, bool) 100 | GetElements() []Element 101 | } 102 | ``` 103 | 104 | - `renderer.Attribute`: represents any kind of element attribute. 105 | 106 | ```go 107 | type Attribute interface { 108 | Renderer 109 | GetKey() string 110 | IsEmpty() bool 111 | } 112 | ``` 113 | 114 | By composing these primitives and building up on them, you can create very well designed UI's with great developer experience. If you love Go like I do, building user interfaces with PacisUI should feel like a breath of fresh air. 115 | 116 | > PacisUI is neither complete nor production ready yet but I am working on it. But this documentation site it built with it so it should give you an idea. -------------------------------------------------------------------------------- /www/app/docs/getting-started/quick-start.md: -------------------------------------------------------------------------------- 1 | # Quick Start -------------------------------------------------------------------------------- /www/app/docs/pages/actions.md: -------------------------------------------------------------------------------- 1 | # Actions -------------------------------------------------------------------------------- /www/app/docs/pages/font.md: -------------------------------------------------------------------------------- 1 | # Font -------------------------------------------------------------------------------- /www/app/docs/pages/i18n.md: -------------------------------------------------------------------------------- 1 | # I18n -------------------------------------------------------------------------------- /www/app/docs/pages/layouts.md: -------------------------------------------------------------------------------- 1 | # Layouts -------------------------------------------------------------------------------- /www/app/docs/pages/middleware.md: -------------------------------------------------------------------------------- 1 | # Middleware -------------------------------------------------------------------------------- /www/app/docs/pages/overview.md: -------------------------------------------------------------------------------- 1 | # Pages Overview -------------------------------------------------------------------------------- /www/app/docs/pages/pages.md: -------------------------------------------------------------------------------- 1 | # Pages -------------------------------------------------------------------------------- /www/app/docs/pages/prefetching.md: -------------------------------------------------------------------------------- 1 | # Prefetching -------------------------------------------------------------------------------- /www/app/docs/pages/streaming.md: -------------------------------------------------------------------------------- 1 | # Streaming -------------------------------------------------------------------------------- /www/app/docs/ui/components/alert.md: -------------------------------------------------------------------------------- 1 | # Alert 2 | 3 | {subtitle=""} 4 | Displays a callout for user attention. 5 | 6 | {plate="0"} 7 | ```go 8 | Alert( 9 | icons.Code(), 10 | AlertTitle(Text("Heads up!")), 11 | AlertDescription(Text("You can us Go tho create great UI's")), 12 | ) 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```go 18 | import ( 19 | . "github.com/canpacis/pacis/ui/components" 20 | "github.com/canpacis/pacis/ui/icons" 21 | ) 22 | ``` 23 | 24 | ```go 25 | Alert( 26 | icons.Code(), 27 | AlertTitle(Text("Heads up!")), 28 | AlertDescription(Text("You can us Go tho create great UI's")), 29 | ) 30 | ``` -------------------------------------------------------------------------------- /www/app/docs/ui/components/avatar.md: -------------------------------------------------------------------------------- 1 | # Avatar 2 | 3 | {subtitle=""} 4 | An image element with a fallback for representing the user. 5 | 6 | {plate=0} 7 | ```go 8 | Avatar( 9 | AvatarImage(Src(imgsrc)), 10 | AvatarFallback(Text("MC")), 11 | ) 12 | ``` 13 | 14 | ## Usage 15 | 16 | ```go 17 | import ( 18 | . "github.com/canpacis/pacis/ui/components" 19 | ) 20 | ``` 21 | 22 | ```go 23 | Avatar( 24 | AvatarImage(Src(...)), 25 | AvatarFallback(Text("MC")) 26 | ) 27 | ``` 28 | 29 | ## Examples 30 | 31 | ### With Image 32 | 33 | {plate=0} 34 | ```go 35 | Avatar( 36 | AvatarImage(Src(imgsrc)), 37 | AvatarFallback(Text("MC")), 38 | ) 39 | ``` 40 | 41 | ### Without Image 42 | 43 | {plate=1} 44 | ```go 45 | Avatar( 46 | AvatarFallback(Text("MC")), 47 | ) 48 | ``` 49 | 50 | ### Sizes 51 | 52 | {plate=2} 53 | ```go 54 | Frag( 55 | Avatar( 56 | AvatarSizeSm, 57 | 58 | AvatarImage(Src(imgsrc)), 59 | AvatarFallback(Text("MC")), 60 | ), 61 | Avatar( 62 | AvatarImage(Src(imgsrc)), 63 | AvatarFallback(Text("MC")), 64 | ), 65 | Avatar( 66 | AvatarSizeLg, 67 | 68 | AvatarImage(Src(imgsrc)), 69 | AvatarFallback(Text("MC")), 70 | ), 71 | ) 72 | ``` 73 | 74 | ## API 75 | 76 | ### Events 77 | 78 | | Event | Description | 79 | |---|---| 80 | | `error` | Fires when given source image fails to load. | -------------------------------------------------------------------------------- /www/app/docs/ui/components/badge.md: -------------------------------------------------------------------------------- 1 | # Badge 2 | 3 | {subtitle=""} 4 | Displays a badge or a component that looks like a badge. 5 | 6 | {plate=0} 7 | ```go 8 | Badge(Text("Badge")) 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```go 14 | import ( 15 | . "github.com/canpacis/pacis/ui/components" 16 | ) 17 | ``` 18 | 19 | ```go 20 | Badge(Text("Badge")) 21 | ``` 22 | 23 | ## Examples 24 | 25 | ### Primary 26 | 27 | {plate=0} 28 | ```go 29 | Badge(Text("Badge")) 30 | ``` 31 | 32 | ### Secondary 33 | 34 | {plate=1} 35 | ```go 36 | Badge(BadgeVariantSecondary, Text("Secondary")) 37 | ``` 38 | 39 | ### Outline 40 | 41 | {plate=2} 42 | ```go 43 | Badge(BadgeVariantOutline, Text("Outline")) 44 | ``` 45 | 46 | ### Destructive 47 | 48 | {plate=3} 49 | ```go 50 | Badge(BadgeVariantDestructive, Text("Destructive")) 51 | ``` -------------------------------------------------------------------------------- /www/app/docs/ui/components/button.md: -------------------------------------------------------------------------------- 1 | # Button 2 | 3 | {subtitle=""} 4 | Displays a button or a component that looks like a button. 5 | 6 | {plate="0"} 7 | ```go 8 | Button(Text("Button")) 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```go 14 | import ( 15 | . "github.com/canpacis/pacis/ui/components" 16 | ) 17 | ``` 18 | 19 | ```go 20 | Button(Text("Button")) 21 | ``` 22 | 23 | ## Examples 24 | 25 | ### Primary variant 26 | 27 | {plate=0} 28 | ```go 29 | Button( 30 | Text("Button"), 31 | ) 32 | ``` 33 | 34 | ### Secondary variant 35 | 36 | {plate=1} 37 | ```go 38 | Button( 39 | ButtonVariantSecondary, 40 | 41 | Text("Secondary"), 42 | ) 43 | ``` 44 | 45 | ### Outline variant 46 | 47 | {plate=2} 48 | ```go 49 | Button( 50 | ButtonVariantOutline, 51 | 52 | Text("Outline"), 53 | ) 54 | ``` 55 | 56 | ### Destructive variant 57 | 58 | {plate=3} 59 | ```go 60 | Button( 61 | ButtonVariantDestructive, 62 | 63 | Text("Destructive"), 64 | ) 65 | ``` 66 | 67 | ### Ghost variant 68 | 69 | {plate=4} 70 | ```go 71 | Button( 72 | ButtonVariantGhost, 73 | 74 | Text("Ghost"), 75 | ) 76 | ``` 77 | 78 | ### Link variant 79 | 80 | {plate=5} 81 | ```go 82 | Button( 83 | ButtonVariantLink, 84 | 85 | Text("Link"), 86 | ) 87 | ``` 88 | 89 | ### Sizes 90 | 91 | {plate=6} 92 | ```go 93 | Frag( // <- HTML Fragment 94 | Button( 95 | ButtonSizeSm, 96 | 97 | Text("Small"), 98 | ), 99 | Button( 100 | Text("Default"), 101 | ), 102 | Button( 103 | ButtonSizeLg, 104 | 105 | Text("Large"), 106 | ) 107 | ) 108 | ``` 109 | 110 | ### Icon 111 | 112 | {plate=7} 113 | ```go 114 | Button( 115 | ButtonSizeIcon, 116 | ButtonVariantOutline, 117 | 118 | icons.EllipsisVertical(), 119 | ) 120 | ``` 121 | 122 | ### Button as link 123 | 124 | {plate=8} 125 | ```go 126 | Button( 127 | Replace(A), // <- Replace with an anchor tag 128 | Href("#button-as-link"), // <- Provide an href 129 | ButtonVariantOutline, 130 | 131 | Text("This is a link"), 132 | ) 133 | ``` 134 | 135 | ### With an event handler 136 | 137 | {plate=9} 138 | ```go 139 | Button( 140 | On("click", "alert('Clicked')"), // <- This comes from the components module 141 | 142 | Text("Press Me!"), 143 | ) 144 | ``` 145 | 146 | ## API 147 | 148 | ### Events 149 | 150 | > All the events of a regular DOM button element is passed to this node -------------------------------------------------------------------------------- /www/app/docs/ui/components/calendar.md: -------------------------------------------------------------------------------- 1 | # Calendar -------------------------------------------------------------------------------- /www/app/docs/ui/components/card.md: -------------------------------------------------------------------------------- 1 | # Card 2 | 3 | {subtitle=""} 4 | Displays a card with header, content, and footer. 5 | 6 | {plate=0} 7 | ```go 8 | Card( 9 | Class("w-fit sm:min-w-[380px]"), 10 | 11 | CardHeader( 12 | CardTitle(Text("Notifications")), 13 | CardDescription(Text("You have 3 unread messages.")), 14 | ), 15 | CardContent( 16 | Class("grid gap-4"), 17 | 18 | Div( 19 | Class("flex items-center space-x-4 rounded-md border p-4"), 20 | 21 | icons.BellRing(), 22 | Div( 23 | Class("flex-1 space-y-1"), 24 | 25 | P(Class("text-sm font-medium leading-none line-clamp-1"), Text("Push Notifications")), 26 | P( 27 | Class("text-sm text-muted-foreground line-clamp-2"), 28 | 29 | Text("Send notifications to device."), 30 | ), 31 | ), 32 | Checkbox(Name("Enable Notifications"), Span(Class("sr-only"), Text("Enable Notifications"))), 33 | ), 34 | Div( 35 | Map(notifications, func(n notification, i int) Node { 36 | return Div( 37 | Class("mb-4 grid grid-cols-[25px_1fr] items-start pb-4 last:mb-0 last:pb-0"), 38 | 39 | Span(Class("flex h-2 w-2 translate-y-1 rounded-full bg-sky-500")), 40 | Div( 41 | Class("space-y-1"), 42 | 43 | P(Class("text-sm font-medium leading-none"), Text(n.title)), 44 | P(Class("text-sm text-muted-foreground"), Text(n.description)), 45 | ), 46 | ) 47 | }), 48 | ), 49 | ), 50 | CardFooter( 51 | Button( 52 | Class("w-full"), 53 | 54 | icons.Check(), 55 | Text("Mark all as read"), 56 | ), 57 | ), 58 | ) 59 | ``` 60 | 61 | ## Usage 62 | 63 | ```go 64 | import ( 65 | . "github.com/canpacis/pacis/ui/components" 66 | ) 67 | ``` 68 | 69 | ```go 70 | Card( 71 | CardHeader( 72 | CardTitle(Text("Title")), 73 | CardDescription(Text("Description")), 74 | ), 75 | CardContent( 76 | // Content 77 | ), 78 | CardFooter( 79 | // Footer 80 | ), 81 | ), 82 | ``` 83 | ## Examples 84 | 85 | ### Notifcation 86 | 87 | {plate=0} 88 | ```go 89 | import ( 90 | . "github.com/canpacis/pacis/ui/html" 91 | . "github.com/canpacis/pacis/ui/components" 92 | "github.com/canpacis/pacis/ui/icons" 93 | ) 94 | 95 | // ... 96 | 97 | type Notification struct { 98 | Title string 99 | Description string 100 | } 101 | 102 | var notifications = []Notification{ 103 | {"Your call has been confirmed.", "1 hour ago"}, 104 | {"You have a new message!", "1 hour ago"}, 105 | {"Your subscription is expiring soon!", "2 hours ago"}, 106 | } 107 | 108 | //... 109 | 110 | Card( 111 | Class("w-[380px]"), 112 | 113 | CardHeader( 114 | CardTitle(Text("Notifications")), 115 | CardDescription(Text("You have 3 unread messages.")), 116 | ), 117 | CardContent( 118 | Class("grid gap-4"), 119 | 120 | Div( 121 | Class("flex items-center space-x-4 rounded-md border p-4"), 122 | 123 | icons.BellRing(), 124 | Div( 125 | Class("flex-1 space-y-1"), 126 | 127 | P(Class("text-sm font-medium leading-none"), Text("Push Notifications")), 128 | P( 129 | Class("text-sm text-muted-foreground"), 130 | 131 | Text("Send notifications to device."), 132 | ), 133 | ), 134 | Checkbox(), 135 | ), 136 | Div( 137 | Map(notifications, func(n Notification, i int) Node { 138 | return Div( 139 | Class("mb-4 grid grid-cols-[25px_1fr] items-start pb-4 last:mb-0 last:pb-0"), 140 | 141 | Span(Class("flex h-2 w-2 translate-y-1 rounded-full bg-sky-500")), 142 | Div( 143 | Class("space-y-1"), 144 | 145 | P(Class("text-sm font-medium leading-none"), Text(n.Title)), 146 | P(Class("text-sm text-muted-foreground"), Text(n.Description)), 147 | ), 148 | ) 149 | }), 150 | ), 151 | ), 152 | CardFooter( 153 | Button( 154 | Class("w-full"), 155 | 156 | icons.Check(), 157 | Text("Mark all as read"), 158 | ), 159 | ), 160 | ) 161 | ``` -------------------------------------------------------------------------------- /www/app/docs/ui/components/carousel.md: -------------------------------------------------------------------------------- 1 | # Carousel -------------------------------------------------------------------------------- /www/app/docs/ui/components/checkbox.md: -------------------------------------------------------------------------------- 1 | # Checkbox 2 | 3 | {.text-muted-foreground} 4 | A control that allows the user to toggle between checked and not checked. 5 | 6 | {plate=0} 7 | ```go 8 | Checkbox() 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```go 14 | import ( 15 | . "github.com/canpacis/pacis/ui/components" 16 | ) 17 | ``` 18 | 19 | ```go 20 | Checkbox() 21 | ``` 22 | 23 | ## Examples 24 | 25 | ### With label 26 | 27 | {plate=1} 28 | ```go 29 | Checkbox(Text("Label")) 30 | ``` 31 | 32 | ### Default checked 33 | 34 | {plate=2} 35 | ```go 36 | Checkbox(Checked) 37 | ``` 38 | 39 | ### With an event handler 40 | 41 | {plate=3} 42 | ```go 43 | Checkbox(On("changed", "alert($event.detail.checked)")), 44 | ``` 45 | 46 | ## API 47 | 48 | ### Events 49 | 50 | | Event | Description | 51 | |---|---| 52 | | `init` | Fires upon initialization and sends its initial state. | 53 | | `changed` | Fires when the checkbox state changes. You can find the `boolean` value on the `$event.detail` object | 54 | 55 | ### Functions 56 | 57 | | Signature | Description | 58 | |---|---| 59 | | `toggleCheckbox(): void` | Toggles the checkbox state. | 60 | | `isChecked(): boolean` | Returns the checkbox state. | 61 | 62 | ### Go Attributes 63 | 64 | | Signature | Description | 65 | |---|---| 66 | | `ToggleCheckbox` | Toggles the checkbox on click. | 67 | | `ToggleChecboxOn(string)` | Toggles the checkbox upon given event. | 68 | 69 | ### State 70 | 71 | You can reach to a checkbox\'s state outside of the component by providing an explicit id to it. 72 | 73 | ```go 74 | Checkbox(ID("test")) 75 | // Somewhere else 76 | Div(X("text", "$checkbox('test').isChecked()")) // <- use the api via the alpine magic 77 | ``` 78 | 79 | > Every checkbox, whether you provide an explicit id or not, is registered to this global store upon initialization. -------------------------------------------------------------------------------- /www/app/docs/ui/components/code.md: -------------------------------------------------------------------------------- 1 | # Code -------------------------------------------------------------------------------- /www/app/docs/ui/components/collapsible.md: -------------------------------------------------------------------------------- 1 | # Colapsible 2 | 3 | {.text-muted-foreground} 4 | An interactive component which expands/collapses a panel. 5 | 6 | {plate=0} 7 | ```go 8 | Collapsible( 9 | Class("min-w-[200px] flex flex-col gap-2 items-center"), 10 | 11 | CollapsibleTrigger( 12 | Button(Text("Trigger")), 13 | ), 14 | CollapsibleContent( 15 | Div(Text("Collapsible Content")), 16 | ), 17 | ) 18 | ``` 19 | 20 | ## Usage 21 | 22 | ```go 23 | import ( 24 | . "github.com/canpacis/pacis-ui/components" 25 | ) 26 | ``` 27 | 28 | ```go 29 | Collapsible( 30 | CollapsibleTrigger( 31 | Button(Text("Trigger")), 32 | ), 33 | CollapsibleContent( 34 | Div(Text("Collapsible Content")), 35 | ), 36 | ) 37 | ``` 38 | 39 | ## Examples 40 | 41 | ### Simple collapsible 42 | 43 | {plate=0} 44 | ```go 45 | Collapsible( 46 | Class("min-w-[200px] flex flex-col gap-2 items-center"), 47 | 48 | CollapsibleTrigger( 49 | Button(Text("Trigger")), 50 | ), 51 | CollapsibleContent( 52 | Div(Text("Collapsible Content")), 53 | ), 54 | ) 55 | ``` 56 | 57 | ## API 58 | 59 | ### Events 60 | 61 | | Event | Description | 62 | |---|---| 63 | | `init` | Fires upon initialization and sends its initial state. | 64 | | `changed` | Fires when the collapse state changes. You can find the `boolean` value on the `$event.detail` object | 65 | 66 | ### Functions 67 | 68 | | Name | Description | 69 | |---|---| 70 | | `toggleCollapse()` | Toggles the collapse state. | 71 | 72 | ### Go Attributes 73 | 74 | | Signature | Description | 75 | |---|---| 76 | | `ToggleCollapse` | Toggles the collapsible on click. | 77 | | `ToggleCollapseOn(string)` | Toggles the collapsible upon given event. | -------------------------------------------------------------------------------- /www/app/docs/ui/components/dialog.md: -------------------------------------------------------------------------------- 1 | # Dialog 2 | 3 | {.text-muted-foreground} 4 | A window overlaid on either the primary window or another dialog window, rendering the content underneath inert. 5 | 6 | {plate=0} 7 | ```go 8 | Dialog( 9 | DialogTrigger( 10 | Button(Text("Open Dialog")), 11 | ), 12 | DialogContent( 13 | Class("max-w-[92dvw] sm:max-w-[420px]"), 14 | 15 | DialogHeader( 16 | DialogTitle(Text("Are you absolutely sure?")), 17 | DialogDescription(Text("This action cannot be undone. This will permanently delete your account and remove your data from our servers.")), 18 | ), 19 | ), 20 | ) 21 | ``` 22 | 23 | ## Usage 24 | 25 | ```go 26 | import ( 27 | . "github.com/canpacis/pacis/ui/components" 28 | ) 29 | ``` 30 | 31 | ```go 32 | Dialog( 33 | DialogTrigger( 34 | Button(Text("Open Dialog")), 35 | ), 36 | DialogContent( 37 | DialogHeader( 38 | DialogTitle(Text("Are you absolutely sure?")), 39 | DialogDescription(Text("This action cannot be undone. This will permanently delete your account and remove your data from our servers.")), 40 | ), 41 | ), 42 | ) 43 | ``` 44 | 45 | ## Examples 46 | 47 | ### Form dialog 48 | 49 | {plate=1} 50 | ```go 51 | Dialog( 52 | DialogTrigger( 53 | Button(Text("Open Dialog")), 54 | ), 55 | DialogContent( 56 | Class("sm:max-w-[425px]"), 57 | 58 | DialogHeader( 59 | DialogTitle(Text("Edit profile")), 60 | DialogDescription(Text("Make changes to your profile here. Click save when you're done.")), 61 | ), 62 | Div( 63 | Class("grid gap-4 py-4"), 64 | 65 | Div( 66 | Class("grid grid-cols-4, items-center gap-4"), 67 | 68 | Label(HtmlFor("name"), Class("text-right"), Text("Name")), 69 | Input(ID("name"), Class("col-span-3")), 70 | ), 71 | Div( 72 | Class("grid grid-cols-4, items-center gap-4"), 73 | 74 | Label(HtmlFor("username"), Class("text-right"), Text("Username")), 75 | Input(ID("username"), Class("col-span-3")), 76 | ), 77 | ), 78 | DialogFooter( 79 | Button(Type("submit"), Text("Save Changes")), 80 | ), 81 | ), 82 | ) 83 | ``` 84 | 85 | ## API 86 | 87 | ### Events 88 | 89 | | Event | Description | 90 | |---|---| 91 | | `init` | Fires upon initialization and sends dialogs open state. | 92 | | `opened` | Fires when the dialog is opened. You can find the original event on the `$event.detail` object. | 93 | | `closed` | Fires when the dialog is **explicitly** closed. You can find the original event and the data associated with the event on the `$event.detail` object. | 94 | | `dismissed` | Fires when the dialog is dismissed rather than closed. You can find the original event on the `$event.detail` object. | 95 | 96 | ### Functions 97 | 98 | | Name | Description | 99 | |---|---| 100 | | `openDialog()` | Opens the dialog. | 101 | | `closeDialog(data: unknown)` | Closes the dialog with some data. | 102 | | `dismissDialog()` | Dismisses the dialog. | 103 | 104 | ### Go Attributes 105 | 106 | | Signature | Description | 107 | |---|---| 108 | | `OpenDialog` | Opens the dialog on click. | 109 | | `OpenDialogOn(string)` | Opens the dialog upon given event. | 110 | | `CloseDialog(D)` | Closes the dialog with the serializable data on click. | 111 | | `CloseDialog(string, D)` | Closes the dialog with the serializble data upon given event. | 112 | | `DismissDialog` | Dismisses the dialog on click. | 113 | | `DismissDialogOn(string)` | Dismisses the dialog upon given event. | -------------------------------------------------------------------------------- /www/app/docs/ui/components/dropdown.md: -------------------------------------------------------------------------------- 1 | # Dropdown 2 | 3 | {.text-muted-foreground} 4 | Displays a menu to the user — such as a set of actions or functions — triggered by a button. 5 | 6 | {plate=0} 7 | ```go 8 | Dropdown( 9 | DropdownTrigger( 10 | Button(Text("Open Menu")), 11 | ), 12 | DropdownContent( 13 | DropdownItem( 14 | ID("profile"), 15 | 16 | icons.User(), 17 | Text("Profile"), 18 | ), 19 | DropdownItem( 20 | ID("settings"), 21 | 22 | icons.Settings(), 23 | Text("Settings"), 24 | ), 25 | ), 26 | ) 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```go 32 | import ( 33 | . "github.com/canpacis/pacis/ui/html" 34 | . "github.com/canpacis/pacis/ui/components" 35 | ) 36 | ``` 37 | 38 | ```go 39 | Dropdown( 40 | DropdownTrigger( 41 | Button(Text("Open Menu")), 42 | ), 43 | DropdownContent( 44 | DropdownItem( 45 | ID("item-id"), 46 | 47 | Text("Dropdown Item"), 48 | ), 49 | ), 50 | ) 51 | ``` -------------------------------------------------------------------------------- /www/app/docs/ui/components/input.md: -------------------------------------------------------------------------------- 1 | # Input 2 | 3 | {.text-muted-foreground} 4 | Displays a form input field or a component that looks like an input field. 5 | 6 | {plate=0} 7 | ```go 8 | Input(Placeholder("Email")) 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```go 14 | Input(Placeholder("Email")) 15 | ``` -------------------------------------------------------------------------------- /www/app/docs/ui/components/label.md: -------------------------------------------------------------------------------- 1 | # Label 2 | 3 | {.text-muted-foreground} 4 | Renders an accessible label associated with controls. 5 | 6 | {plate=0} 7 | ```go 8 | Label("Email", Input(Placeholder("canpacis@gmail.com"))) 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```go 14 | Label("Label Text", Input()) 15 | ``` -------------------------------------------------------------------------------- /www/app/docs/ui/components/overview.md: -------------------------------------------------------------------------------- 1 | # Components Overview -------------------------------------------------------------------------------- /www/app/docs/ui/components/radio.md: -------------------------------------------------------------------------------- 1 | # Radio Group 2 | 3 | {.text-muted-foreground} 4 | A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time. 5 | 6 | {plate=0} 7 | ```go 8 | Div( 9 | D{"submitted": ""}, 10 | Form( 11 | On("submit.prevent", "submitted = new FormData($event.target).get('radio-group')"), 12 | 13 | RadioGroup( 14 | Name("radio-group"), 15 | Value("item-2"), 16 | 17 | RadioGroupItem(Value("item-1"), Text("Radio Item 1")), 18 | RadioGroupItem(Value("item-2"), Text("Radio Item 2")), 19 | RadioGroupItem(Value("item-3"), Text("Radio Item 3")), 20 | ), 21 | Button(Class("mt-2"), Type("submit"), Text("Submit")), 22 | ), 23 | Div( 24 | Class("mt-4"), 25 | 26 | P(X("show", "submitted.length > 0"), Span(Text("Submitted: ")), Span(X("text", "submitted"))), 27 | ), 28 | ) 29 | ``` -------------------------------------------------------------------------------- /www/app/docs/ui/components/select.md: -------------------------------------------------------------------------------- 1 | # Select 2 | 3 | {.text-muted-foreground} 4 | Displays a list of options for the user to pick from, triggered by a button. 5 | 6 | {plate=0} 7 | ```go 8 | Select( 9 | Name("library"), 10 | Class("min-w-[200px]"), 11 | 12 | SelectTrigger( 13 | Span(Text("Select a library")), 14 | Span(X("text", "value")), 15 | ), 16 | SelectContent( 17 | SelectItem(Value("Templ"), Text("Templ")), 18 | SelectItem(Value("Gomponents"), Text("Gomponents")), 19 | SelectItem(Value("Pacis"), Text("Pacis")), 20 | ), 21 | ) 22 | ``` 23 | 24 | ## Usage 25 | 26 | ```go 27 | Select( 28 | Name("name"), 29 | 30 | SelectTrigger( 31 | Span(Text("Empty Trigger")), 32 | Span(X("text", "value")), // <- Selected value 33 | ), 34 | SelectContent( 35 | SelectItem(Value("item-1"), Text("Item 1")), 36 | SelectItem(Value("item-2"), Text("Item 2")), 37 | ), 38 | ) 39 | ``` 40 | 41 | ## Examples 42 | 43 | ### Clearable 44 | 45 | {plate=1} 46 | ```go 47 | Select( 48 | Name("library"), 49 | Class("min-w-[200px]"), 50 | Clearable 51 | 52 | SelectTrigger( 53 | Span(Text("Select a library")), 54 | Span(X("text", "value")), 55 | ), 56 | SelectContent( 57 | SelectItem(Value("Templ"), Text("Templ")), 58 | SelectItem(Value("Gomponents"), Text("Gomponents")), 59 | SelectItem(Value("Pacis"), Text("Pacis")), 60 | ), 61 | ) 62 | ``` 63 | 64 | ### Default value 65 | 66 | {plate=2} 67 | ```go 68 | Select( 69 | Name("library"), 70 | Value("Pacis"), 71 | Class("min-w-[200px]"), 72 | Clearable 73 | 74 | SelectTrigger( 75 | Span(Text("Select a library")), 76 | Span(X("text", "value")), 77 | ), 78 | SelectContent( 79 | SelectItem(Value("Templ"), Text("Templ")), 80 | SelectItem(Value("Gomponents"), Text("Gomponents")), 81 | SelectItem(Value("Pacis"), Text("Pacis")), 82 | ), 83 | ) 84 | ``` -------------------------------------------------------------------------------- /www/app/docs/ui/components/seperator.md: -------------------------------------------------------------------------------- 1 | # Seperator -------------------------------------------------------------------------------- /www/app/docs/ui/components/sheet.md: -------------------------------------------------------------------------------- 1 | # Sheet -------------------------------------------------------------------------------- /www/app/docs/ui/components/slider.md: -------------------------------------------------------------------------------- 1 | # Slider -------------------------------------------------------------------------------- /www/app/docs/ui/components/switch.md: -------------------------------------------------------------------------------- 1 | # Switch 2 | 3 | {.text-muted-foreground} 4 | A control that allows the user to toggle between checked and not checked. 5 | 6 | {plate=0} 7 | ```go 8 | Switch() 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```go 14 | import ( 15 | . "github.com/canpacis/pacis/ui/components" 16 | ) 17 | ``` 18 | 19 | ```go 20 | Switch() 21 | ``` 22 | 23 | ## Examples 24 | 25 | ### With label 26 | 27 | {plate=1} 28 | ```go 29 | Switch(Text("Label")) 30 | ``` 31 | 32 | ### Default checked 33 | 34 | {plate=2} 35 | ```go 36 | Switch(Checked) 37 | ``` 38 | 39 | ### With an event handler 40 | 41 | {plate=3} 42 | ```go 43 | Switch(On("changed", "alert($event.detail.checked)")), 44 | ``` 45 | 46 | ## API 47 | 48 | ### Events 49 | 50 | | Event | Description | 51 | |---|---| 52 | | `init` | Fires upon initialization and sends its initial state. | 53 | | `changed` | Fires when the switch state changes. You can find the `boolean` value on the `$event.detail` object | 54 | 55 | ### Functions 56 | 57 | | Signature | Description | 58 | |---|---| 59 | | `toggleSwitch(): void` | Toggles the siwtch state. | 60 | | `isChecked(): boolean` | Returns the siwtch state. | 61 | 62 | ### Go Attributes 63 | 64 | | Signature | Description | 65 | |---|---| 66 | | `ToggleSwitch` | Toggles the switch on click. | 67 | | `ToggleSwitchOn(string)` | Toggles the switch upon given event. | 68 | 69 | ### State 70 | 71 | You can reach to a switch\'s state outside of the component by providing an explicit id to it. 72 | 73 | ```go 74 | Switch(ID("test")) 75 | // Somewhere else 76 | Div(X("text", "$switch_('test').isChecked()")) // <- use the api via the alpine magic 77 | ``` 78 | 79 | > Every switch, whether you provide an explicit id or not, is registered to this global store upon initialization. -------------------------------------------------------------------------------- /www/app/docs/ui/components/table.md: -------------------------------------------------------------------------------- 1 | # Table -------------------------------------------------------------------------------- /www/app/docs/ui/components/tabs.md: -------------------------------------------------------------------------------- 1 | # Tabs 2 | 3 | {.text-muted-foreground} 4 | A set of layered sections of content—known as tab panels—that are displayed one at a time. 5 | 6 | {plate=0} 7 | 8 | ```go 9 | Tabs( 10 | Value("tab-item-1"), // <- Default value 11 | 12 | TabList( 13 | TabTrigger(Text("Tab Item 1"), Value("tab-item-1")), // Value attributes are required 14 | TabTrigger(Text("Tab Item 2"), Value("tab-item-2")), 15 | ), 16 | TabContent( 17 | Value("tab-item-1"), // Value attributes are required 18 | 19 | P(Text("Tab item 1 content")), 20 | ), 21 | TabContent( 22 | Value("tab-item-2"), 23 | 24 | P(Text("Tab item 2 content")), 25 | ), 26 | ) 27 | ``` 28 | 29 | ## Usage 30 | 31 | ```go 32 | Tabs( 33 | TabList( 34 | TabTrigger(Text("Trigger"), Value("value")), 35 | ), 36 | TabContent( 37 | Value("value"), 38 | 39 | // Content 40 | ), 41 | ) 42 | ``` 43 | -------------------------------------------------------------------------------- /www/app/docs/ui/components/textarea.md: -------------------------------------------------------------------------------- 1 | # Textarea -------------------------------------------------------------------------------- /www/app/docs/ui/components/toast.md: -------------------------------------------------------------------------------- 1 | # Toast -------------------------------------------------------------------------------- /www/app/docs/ui/components/tooltip.md: -------------------------------------------------------------------------------- 1 | # Tooltip 2 | 3 | {plate=0} 4 | ```go 5 | ``` -------------------------------------------------------------------------------- /www/app/docs/ui/icons/custom-icons.md: -------------------------------------------------------------------------------- 1 | # Custom Icons -------------------------------------------------------------------------------- /www/app/docs/ui/templating/attributes.md: -------------------------------------------------------------------------------- 1 | # Attributes -------------------------------------------------------------------------------- /www/app/docs/ui/templating/elements.md: -------------------------------------------------------------------------------- 1 | # Elements -------------------------------------------------------------------------------- /www/app/docs/ui/templating/extending.md: -------------------------------------------------------------------------------- 1 | # Extending -------------------------------------------------------------------------------- /www/app/docs/ui/templating/utilities.md: -------------------------------------------------------------------------------- 1 | # Utilities -------------------------------------------------------------------------------- /www/app/home.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | _ "embed" 5 | 6 | "github.com/canpacis/pacis/pages" 7 | . "github.com/canpacis/pacis/ui/components" 8 | . "github.com/canpacis/pacis/ui/html" 9 | ) 10 | 11 | //pacis:language default=en 12 | //pacis:page path=/ middlewares=auth 13 | func HomePage(ctx *pages.Context) I { 14 | pages.SetMetadata(ctx, &pages.Metadata{Title: "Homepage | Pacis"}) 15 | pages.SetHeader(ctx, pages.NewHeader("Host", "canpacis.com")) 16 | 17 | return Main( 18 | Class("container my-8 lg:my-16 flex-1 flex flex-col lg:flex-row items-start md:items-center gap-8 mt:0 lg:-mt-[var(--footer-height)]"), 19 | 20 | Div( 21 | Class("flex-0 lg:flex-3"), 22 | 23 | H1( 24 | Class("text-2xl font-bold leading-tight tracking-tighter sm:text-3xl md:text-4xl lg:leading-[1.1]"), 25 | 26 | Text("Build web applications "), 27 | Span(Class("relative inline-block px-2 py-1 rounded-lg bg-gradient-to-r from-blue-300 via-purple-300 to-pink-300 dark:from-blue-500 dark:via-purple-500 dark:to-pink-500"), Text("with Go")), 28 | ), 29 | P( 30 | Class("max-w-2xl text-base font-light text-foreground sm:text-lg mt-4"), 31 | 32 | Text("Build stunning, modern UIs for Go applications with ease, intuitive components, flexible styling, and seamless performance."), 33 | ), 34 | Div( 35 | Class("mt-8 flex gap-2"), 36 | 37 | Button( 38 | pages.Eager, 39 | Replace(pages.A), 40 | Href("/docs"), 41 | Class("!rounded-full"), 42 | ButtonSizeLg, 43 | 44 | Text("Get Started"), 45 | ), 46 | Button( 47 | ButtonSizeLg, 48 | ButtonVariantGhost, 49 | pages.Eager, 50 | Replace(pages.A), 51 | Class("!rounded-full"), 52 | Href("/docs/components"), 53 | 54 | Text("See Components"), 55 | ), 56 | ), 57 | ), 58 | Div(), 59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /www/app/icons.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/canpacis/pacis/pages" 5 | . "github.com/canpacis/pacis/ui/components" 6 | . "github.com/canpacis/pacis/ui/html" 7 | ) 8 | 9 | func GoogleIcon(props ...I) Element { 10 | return El("svg", 11 | Join( 12 | props, 13 | Attr("viewBox", "0 0 50 50"), 14 | 15 | El("path", 16 | Attr("fill-rule", "evenodd"), 17 | Attr("clip-rule", "evenodd"), 18 | Attr("stroke", "currentColor"), 19 | Attr("d", "M 26 2 C 13.308594 2 3 12.308594 3 25 C 3 37.691406 13.308594 48 26 48 C 35.917969 48 41.972656 43.4375 45.125 37.78125 C 48.277344 32.125 48.675781 25.480469 47.71875 20.9375 L 47.53125 20.15625 L 46.75 20.15625 L 26 20.125 L 25 20.125 L 25 30.53125 L 36.4375 30.53125 C 34.710938 34.53125 31.195313 37.28125 26 37.28125 C 19.210938 37.28125 13.71875 31.789063 13.71875 25 C 13.71875 18.210938 19.210938 12.71875 26 12.71875 C 29.050781 12.71875 31.820313 13.847656 33.96875 15.6875 L 34.6875 16.28125 L 41.53125 9.4375 L 42.25 8.6875 L 41.5 8 C 37.414063 4.277344 31.960938 2 26 2 Z M 26 4 C 31.074219 4 35.652344 5.855469 39.28125 8.84375 L 34.46875 13.65625 C 32.089844 11.878906 29.199219 10.71875 26 10.71875 C 18.128906 10.71875 11.71875 17.128906 11.71875 25 C 11.71875 32.871094 18.128906 39.28125 26 39.28125 C 32.550781 39.28125 37.261719 35.265625 38.9375 29.8125 L 39.34375 28.53125 L 27 28.53125 L 27 22.125 L 45.84375 22.15625 C 46.507813 26.191406 46.066406 31.984375 43.375 36.8125 C 40.515625 41.9375 35.320313 46 26 46 C 14.386719 46 5 36.609375 5 25 C 5 13.390625 14.386719 4 26 4 Z"), 20 | ), 21 | )..., 22 | ) 23 | } 24 | 25 | func GithubIcon(props ...I) Element { 26 | return El("svg", 27 | Join( 28 | props, 29 | Attr("viewBox", "0 0 100 100"), 30 | 31 | El("path", 32 | Attr("fill-rule", "evenodd"), 33 | Attr("clip-rule", "evenodd"), 34 | Attr("fill", "currentColor"), 35 | Attr("d", "M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"), 36 | ), 37 | )..., 38 | ) 39 | } 40 | 41 | func DiscordIcon(props ...I) Element { 42 | return El("svg", 43 | Join( 44 | props, 45 | Attr("viewBox", "0 0 300 200"), 46 | 47 | El( 48 | "g", 49 | El("path", 50 | Attr("fill-rule", "nonzero"), 51 | Attr("fill", "currentColor"), 52 | Attr("d", "M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z"), 53 | ), 54 | ), 55 | )..., 56 | ) 57 | } 58 | 59 | //pacis:page path=/docs/ui/icons/icon-set middlewares=auth 60 | func IconSetPage(ctx *pages.Context) I { 61 | pages.SetMetadata(ctx, &pages.Metadata{Title: "Icon Set | Icons | UI | Pacis Docs"}) 62 | 63 | return Div( 64 | H1(Class("scroll-m-20 text-3xl font-bold tracking-tight"), Text("Icon Set")), 65 | ) 66 | } 67 | 68 | //pacis:page path=/docs/ui/icons/{slug} middlewares=auth 69 | func CustomIconsPage(ctx *pages.Context) I { 70 | slug := ctx.Request().PathValue("slug") 71 | page, ok := dir.Dirs["ui"].Dirs["icons"].Pages[slug] 72 | 73 | if ok { 74 | pages.SetMetadata(ctx, &pages.Metadata{Title: page.Title + " | Icons | UI | Pacis Docs"}) 75 | } else { 76 | return pages.NotFound(ctx) 77 | } 78 | 79 | return DocPageUI(page) 80 | } 81 | -------------------------------------------------------------------------------- /www/app/layout.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | _ "embed" 5 | "os" 6 | "strings" 7 | "time" 8 | 9 | "github.com/canpacis/pacis/pages" 10 | fonts "github.com/canpacis/pacis/pages/font" 11 | "github.com/canpacis/pacis/pages/i18n" 12 | . "github.com/canpacis/pacis/ui/components" 13 | . "github.com/canpacis/pacis/ui/html" 14 | "github.com/canpacis/pacis/ui/icons" 15 | "golang.org/x/text/language" 16 | ) 17 | 18 | var sans = fonts.New("Inter", fonts.WeightList{fonts.W100, fonts.W900}, fonts.Swap, fonts.Latin, fonts.LatinExt) 19 | var mono = fonts.New("JetBrains Mono", fonts.WeightList{fonts.W100, fonts.W800}, fonts.Swap, fonts.Latin, fonts.LatinExt) 20 | 21 | //pacis:page label=robots 22 | //go:embed robots.txt 23 | var robots []byte 24 | 25 | //pacis:page label=sitemap 26 | //go:embed sitemap.xml 27 | var sitemap []byte 28 | 29 | type Layout struct { 30 | User *User `context:"user"` 31 | Locale *language.Tag `context:"locale"` 32 | Theme string `context:"theme"` 33 | } 34 | 35 | //pacis:layout path=/ 36 | func (l *Layout) Layout(ctx *pages.Context) I { 37 | pages.SetHeader(ctx, pages.NewHeader("Host", "canpacis.com")) 38 | 39 | title := i18n.Text("title").String(ctx) 40 | desc := i18n.Text("desc").String(ctx) 41 | keywords := strings.Split(i18n.Text("keywords").String(ctx), ",") 42 | 43 | appurl := os.Getenv("APP_URL") 44 | banner := appurl + pages.Asset("banner.webp") 45 | 46 | pages.SetMetadata(ctx, &pages.Metadata{ 47 | Title: title, 48 | Description: desc, 49 | Keywords: keywords, 50 | Robots: "index, follow", 51 | Authors: []string{"canpacis"}, 52 | Language: l.Locale.String(), 53 | Twitter: &pages.MetadataTwitter{ 54 | Card: "summary_large_image", 55 | URL: appurl, 56 | Title: title, 57 | Description: desc, 58 | Image: banner, 59 | }, 60 | OpenGraph: &pages.MetadataOG{ 61 | Type: "website", 62 | URL: appurl, 63 | Title: title, 64 | Description: desc, 65 | Image: banner, 66 | }, 67 | }) 68 | 69 | return Html( 70 | Class(l.Theme), 71 | Lang(l.Locale.String()), 72 | 73 | Head( 74 | IfFn(l.User != nil, func() Renderer { 75 | return Store("user", l.User) 76 | }), 77 | If(l.User == nil, Store("user", &User{})), 78 | 79 | fonts.Head(sans, mono), 80 | pages.Head(ctx), 81 | Link(Href(pages.Asset("favicon.webp")), Rel("icon"), Type("image/png")), 82 | Script( 83 | Defer, 84 | Src("https://analytics.ui.canpacis.com/script.js"), 85 | Data("website-id", "4ce94416-1fb6-4a90-b881-f2f27a9736f7"), 86 | ), 87 | ), 88 | Body( 89 | Class("flex flex-col min-h-dvh overflow-x-hidden"), 90 | 91 | AppHeader(l.User), 92 | pages.Outlet(ctx), 93 | ToastContainer(), 94 | AppFooter(), 95 | pages.Body(ctx), 96 | ), 97 | ) 98 | } 99 | 100 | func AppHeader(user *User) Element { 101 | links := []NavItem{ 102 | {Label: Text("Docs"), Href: "/docs"}, 103 | {Label: Text("Components"), Href: "/docs/components"}, 104 | } 105 | items := getnavitems() 106 | 107 | return Header( 108 | Class("py-3 border-b border-dashed sticky top-0 z-50 w-full bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-[var(--header-height)]"), 109 | 110 | Div( 111 | Class("flex container items-center gap-4 lg:gap-8 h-full"), 112 | 113 | Sheet( 114 | Class("block lg:hidden"), 115 | 116 | SheetTrigger( 117 | Button( 118 | ButtonSizeIcon, 119 | ButtonVariantGhost, 120 | 121 | icons.PanelLeft(), 122 | Span(Class("sr-only"), Text("Toggle Sidebar")), 123 | ), 124 | ), 125 | SheetContent( 126 | Class("overflow-scroll"), 127 | 128 | Map(items, NavItemUI), 129 | ), 130 | ), 131 | pages.A( 132 | pages.Eager, 133 | Class("flex gap-3 items-center focusable p-2"), 134 | Href("/"), 135 | 136 | Img(Src(pages.Asset("logo.webp")), Width("24"), Height("24"), Class("w-6"), Alt("logo")), 137 | P(Class("font-semibold inline"), Text("Pacis")), 138 | ), 139 | Ul( 140 | Class("hidden gap-4 lg:flex"), 141 | 142 | Map(links, func(link NavItem, i int) I { 143 | return Li( 144 | Class("text-sm text-muted-foreground"), 145 | 146 | pages.A(Href(link.Href), Class("focusable"), link.Label), 147 | ) 148 | }), 149 | ), 150 | Div( 151 | Class("flex gap-1 items-center ml-auto"), 152 | 153 | Tooltip( 154 | "Github", 155 | time.Second*1, 156 | Button( 157 | ButtonSizeIcon, 158 | ButtonVariantGhost, 159 | Replace(pages.A), 160 | Href("https://github.com/canpacis/pacis-ui"), 161 | 162 | GithubIcon(), 163 | Span(Class("sr-only"), Text("Github")), 164 | ), 165 | ), 166 | Tooltip( 167 | "Discord", 168 | time.Second*1, 169 | Button( 170 | ButtonSizeIcon, 171 | ButtonVariantGhost, 172 | Replace(pages.A), 173 | Href("https://discord.gg/QnXQjYZrJU"), 174 | 175 | DiscordIcon(), 176 | Span(Class("sr-only"), Text("Github")), 177 | ), 178 | ), 179 | Tooltip( 180 | "Toggle Theme", 181 | time.Second*1, 182 | Button( 183 | ButtonSizeIcon, 184 | ButtonVariantGhost, 185 | ToggleColorScheme, 186 | 187 | icons.Sun(), 188 | Span(Class("sr-only"), Text("Toggle Theme")), 189 | ), 190 | ), 191 | IfFn(user != nil, func() Renderer { 192 | return Div( 193 | Class("ml-2"), 194 | 195 | Dropdown( 196 | DropdownTrigger( 197 | Span( 198 | Class("cursor-pointer"), 199 | 200 | Avatar( 201 | AvatarImage(Src(user.Picture)), 202 | AvatarFallback(Text("MC")), 203 | ), 204 | ), 205 | ), 206 | DropdownContent( 207 | Anchor(VBottom, HEnd, 8), 208 | 209 | DropdownLabel(user.Email), 210 | DropdownItem(Href("/auth/logout"), Replace(A), icons.LogOut(), Text("Logout")), 211 | ), 212 | ), 213 | ) 214 | }), 215 | ), 216 | ), 217 | ) 218 | } 219 | 220 | func AppFooter() Element { 221 | return Footer( 222 | Class("border-t border-dashed py-2 text-center h-[var(--footer-height)] fixed bottom-0 w-dvw bg-background z-40"), 223 | 224 | P(Class("text-sm text-muted-foreground"), Text("Built by "), pages.A(Href("https://canpacis.com"), Class("hover:underline focusable"), Text("canpacis"))), 225 | ) 226 | } 227 | -------------------------------------------------------------------------------- /www/app/messages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Pacis | Next-Gen Web App Kit", 3 | "desc": "Pacis is a set of utilities that are mainly UI components that help you build beautiful web interfaces with the Go programming language.", 4 | "keywords": "Go, Web, HTML, Templating Engine, JavaScript, TypeScript, UI, UI Library, TailwindCSS, AlpineJS, UI Components, Next JS, SSR, SSG, Server side rendering, Static site generation" 5 | } -------------------------------------------------------------------------------- /www/app/misc.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | 7 | "github.com/canpacis/pacis/pages" 8 | . "github.com/canpacis/pacis/ui/components" 9 | . "github.com/canpacis/pacis/ui/html" 10 | "github.com/canpacis/pacis/ui/icons" 11 | ) 12 | 13 | //pacis:page label=not-found 14 | func NotFoundPage(ctx *pages.Context) I { 15 | // ctx.SetTitle("Not Found | Pacis") 16 | 17 | return Div( 18 | Class("flex flex-col container gap-6 flex-1 items-center justify-center"), 19 | 20 | H1( 21 | Class("text-xl md:text-3xl font-thin flex items-end gap-2 leading-7"), 22 | 23 | icons.FileSearch(Class("size-7"), icons.StrokeWidth(1)), 24 | Span(Text("Not Found!")), 25 | ), 26 | P( 27 | Text("We couldn't find the page you were looking for"), 28 | ), 29 | Button( 30 | Replace(pages.A), 31 | Href("/"), 32 | Class("!rounded-full"), 33 | 34 | Text("Go Home"), 35 | ), 36 | ) 37 | } 38 | 39 | type AppErrorCode int 40 | 41 | const ( 42 | UnknownError = AppErrorCode(iota) 43 | InvalidAuthStateError 44 | AuthExchangeError 45 | ) 46 | 47 | var ErrGenericAppError = errors.New("app error") 48 | 49 | type AppError struct { 50 | code AppErrorCode 51 | err error 52 | status int 53 | } 54 | 55 | func (ae *AppError) Error() string { 56 | return ae.err.Error() 57 | } 58 | 59 | func (ae *AppError) SetError(err error) { 60 | ae.err = err 61 | } 62 | 63 | func (ae *AppError) Status() int { 64 | return ae.status 65 | } 66 | 67 | func (ae *AppError) SetStatus(status int) { 68 | ae.status = status 69 | } 70 | 71 | func (ae *AppError) Unwrap() error { 72 | return ae.err 73 | } 74 | 75 | //pacis:page label=error 76 | func (p *AppError) Page(ctx *pages.Context) I { 77 | // ctx.SetTitle("Error | Pacis") 78 | var message string 79 | 80 | if os.Getenv("ENVIRONMENT") == "development" { 81 | message = p.Error() 82 | } else { 83 | message = "We don't know what happened" 84 | } 85 | 86 | return Div( 87 | Class("flex flex-col container gap-6 flex-1 items-center justify-center"), 88 | 89 | H1( 90 | Class("text-xl md:text-3xl font-thin flex items-end gap-2 leading-7"), 91 | 92 | icons.TriangleAlert(Class("size-7"), icons.StrokeWidth(1)), 93 | Span(Text("Error!")), 94 | ), 95 | P( 96 | SwitchCase( 97 | p.code, 98 | Case(InvalidAuthStateError, Text("There was an error with the auth state")), 99 | Case(AuthExchangeError, Text("Failed to exchange the auth token")), 100 | Case(UnknownError, Text(message)), 101 | ), 102 | ), 103 | Button( 104 | Replace(pages.A), 105 | Href("/"), 106 | Class("!rounded-full"), 107 | 108 | Text("Go Home"), 109 | ), 110 | ) 111 | } 112 | 113 | func NewAppError(code AppErrorCode, err error, status int) *AppError { 114 | return &AppError{code: code, err: err, status: status} 115 | } 116 | -------------------------------------------------------------------------------- /www/app/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: /public/banner.webp 3 | Allow: /public/logo.webp 4 | Allow: / 5 | Disallow: /public/ 6 | 7 | Sitemap: https://ui.canpacis.com/sitemap.xml -------------------------------------------------------------------------------- /www/app/share.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/canpacis/pacis/pages" 7 | . "github.com/canpacis/pacis/ui/html" 8 | ) 9 | 10 | //pacis:page path=/share/{slug} middlewares=auth 11 | func SharePage(ctx *pages.Context) I { 12 | slug := ctx.Request().PathValue("slug") 13 | 14 | query := url.Values{} 15 | query.Add("medium", "cpc") 16 | 17 | if cachedb == nil { 18 | query.Add("campaign", "promo") 19 | } else { 20 | campaign, err := cachedb.Get(ctx, "campaign").Result() 21 | if err == nil { 22 | query.Add("campaign", campaign) 23 | } else { 24 | query.Add("campaign", "promo") 25 | } 26 | } 27 | 28 | switch slug { 29 | case "reddit": 30 | query.Add("source", "reddit") 31 | case "x": 32 | query.Add("source", "x") 33 | case "bsky": 34 | query.Add("source", "bsky") 35 | default: 36 | query.Add("source", "unknown") 37 | } 38 | 39 | return pages.Redirect(ctx, "/?"+query.Encode()) 40 | } 41 | -------------------------------------------------------------------------------- /www/app/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 |